diff --git a/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj b/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj index f01369b..57d906c 100644 --- a/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj +++ b/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj @@ -7,6 +7,14 @@ enable + + + + + + + + diff --git a/EonaCat.Connections.Client/JsonTest.cs b/EonaCat.Connections.Client/JsonTest.cs index 77f0a2e..54b068d 100644 --- a/EonaCat.Connections.Client/JsonTest.cs +++ b/EonaCat.Connections.Client/JsonTest.cs @@ -1,69 +1,72 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace EonaCat.Connections.Client -{ - // Root myDeserializedClass = JsonConvert.DeserializeObject>(myJsonResponse); - public class Contact - { - public string email { get; set; } - public string phone { get; set; } - } - - public class Department - { - public string id { get; set; } - public string name { get; set; } - public Manager manager { get; set; } - } - - public class Details - { - public int hoursSpent { get; set; } - public List technologiesUsed { get; set; } - public string completionDate { get; set; } - public string expectedCompletion { get; set; } - } - - public class Employee - { - public string id { get; set; } - public string name { get; set; } - public string position { get; set; } - public Department department { get; set; } - public List projects { get; set; } - } - - public class Manager - { - public string id { get; set; } - public string name { get; set; } - public Contact contact { get; set; } - } - - public class Project - { - public string projectId { get; set; } - public string projectName { get; set; } - public string startDate { get; set; } - public List tasks { get; set; } - } - - public class Root - { - public Employee employee { get; set; } - } - - public class Task - { - public string taskId { get; set; } - public string title { get; set; } - public string status { get; set; } - public Details details { get; set; } - } - - -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EonaCat.Connections.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. + + // Root myDeserializedClass = JsonConvert.DeserializeObject>(myJsonResponse); + public class Contact + { + public string email { get; set; } + public string phone { get; set; } + } + + public class Department + { + public string id { get; set; } + public string name { get; set; } + public Manager manager { get; set; } + } + + public class Details + { + public int hoursSpent { get; set; } + public List technologiesUsed { get; set; } + public string completionDate { get; set; } + public string expectedCompletion { get; set; } + } + + public class Employee + { + public string id { get; set; } + public string name { get; set; } + public string position { get; set; } + public Department department { get; set; } + public List projects { get; set; } + } + + public class Manager + { + public string id { get; set; } + public string name { get; set; } + public Contact contact { get; set; } + } + + public class Project + { + public string projectId { get; set; } + public string projectName { get; set; } + public string startDate { get; set; } + public List tasks { get; set; } + } + + public class Root + { + public Employee employee { get; set; } + } + + public class Task + { + public string taskId { get; set; } + public string title { get; set; } + public string status { get; set; } + public Details details { get; set; } + } + + +} diff --git a/EonaCat.Connections.Client/Program.cs b/EonaCat.Connections.Client/Program.cs index 2e3a07f..4e4d8d7 100644 --- a/EonaCat.Connections.Client/Program.cs +++ b/EonaCat.Connections.Client/Program.cs @@ -2,12 +2,14 @@ // See the LICENSE file or go to https://EonaCat.com/license for full license details. 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 const bool UseProcessor = true; @@ -62,7 +64,7 @@ namespace EonaCat.Connections.Client.Example { foreach (var client in _clients) { - await client.DisconnectClientAsync().ConfigureAwait(false); + await client.DisconnectAsync().ConfigureAwait(false); } break; } @@ -96,10 +98,9 @@ namespace EonaCat.Connections.Client.Example }; processor.OnProcessMessage += (sender, e) => { - WriteToLog($"Processed JSON message from {e.ClientName} ({e.ClientEndpoint}): {e.RawData}"); + WriteToLog($"Processed JSON message from {e.ClientName} ({e.ClientEndpoint}): {e.Data}"); }; processor.MaxAllowedBufferSize = 50 * 1024 * 1024; // 10 MB - processor.MaxMessagesPerBatch = 5; var json = _jsonContent; while (true) @@ -155,7 +156,6 @@ namespace EonaCat.Connections.Client.Example Protocol = ProtocolType.TCP, Host = SERVER_IP, Port = 1111, - UseSsl = false, UseAesEncryption = false, EnableHeartbeat = IsHeartBeatEnabled, AesPassword = "EonaCat.Connections.Password", @@ -175,16 +175,11 @@ namespace EonaCat.Connections.Client.Example if (UseProcessor) { _clientsProcessors[client] = new JsonDataProcessor>(); - _clientsProcessors[client].OnMessageError += (sender, e) => + _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}"); @@ -192,7 +187,7 @@ namespace EonaCat.Connections.Client.Example _clientsProcessors[client].OnProcessMessage += (sender, e) => { - ProcessMessage(e.RawData, e.ClientName, e.ClientEndpoint ?? "Unknown endpoint"); + ProcessMessage(e.Data, e.ClientName, e.ClientEndpoint ?? "Unknown endpoint"); }; } @@ -200,7 +195,7 @@ namespace EonaCat.Connections.Client.Example { if (UseProcessor) { - _clientsProcessors[client].Process(e.StringData, clientName: e.Nickname); + _clientsProcessors[client].Process(e.StringData, currentClientName: e.Nickname); return; } else diff --git a/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj b/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj index f01369b..3f96243 100644 --- a/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj +++ b/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj @@ -1,14 +1,22 @@ - - - - Exe - net8.0 - enable - enable - - - - - - - + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/EonaCat.Connections.Server/Program.cs b/EonaCat.Connections.Server/Program.cs index 13abc54..6494c8a 100644 --- a/EonaCat.Connections.Server/Program.cs +++ b/EonaCat.Connections.Server/Program.cs @@ -1,157 +1,156 @@ -// 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. - -using EonaCat.Connections.Models; -using System.Reflection; - -namespace EonaCat.Connections.Server.Example -{ - public class Program - { - private const bool IsHeartBeatEnabled = false; - private static NetworkServer _server; - - 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) - { - 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(); - _server.Dispose(); - Console.WriteLine("Server stopped."); - break; - } - - if (!string.IsNullOrEmpty(message)) - { - await _server.BroadcastAsync(message).ConfigureAwait(false); - } - - await Task.Delay(5000).ConfigureAwait(false); - } - } - - private static async Task CreateServerAsync() - { - var config = new Configuration - { - Protocol = ProtocolType.TCP, - Host = "0.0.0.0", - Port = 1111, - UseSsl = false, - UseAesEncryption = false, - MaxConnections = 100000, - AesPassword = "EonaCat.Connections.Password", - //Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("server.pfx", "p@ss"), - EnableHeartbeat = IsHeartBeatEnabled - }; - - _server = new NetworkServer(config); - - // Subscribe to events - _server.OnConnected += (sender, e) => - { - WriteToLog($"Client {e.ClientId} connected from {e.RemoteEndPoint}"); - Console.WriteLine($"New connection from {e.RemoteEndPoint} with Client ID: {e.ClientId}"); - }; - - _server.OnConnectedWithNickname += (sender, e) => WriteToLog($"Client {e.ClientId} connected with nickname: {e.Nickname}"); - - _server.OnDataReceived += async (sender, e) => - { - WriteToLog($"Received from {e.ClientId} ({e.RemoteEndPoint.ToString()}): {(e.IsBinary ? $"{e.Data.Length} bytes" : "a message")}"); - - // Echo back the message - if (e.IsBinary) - { - await _server.SendToClientAsync(e.ClientId, e.Data); - } - else - { - await _server.SendToClientAsync(e.ClientId, e.StringData); - } - }; - - _server.OnDisconnected += (sender, e) => - { - var message = string.Empty; - if (e.Reason == DisconnectReason.LocalClosed) - { - 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 - { - 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); - }; - - _ = _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 - } - } - } +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 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) + { + 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(); + _server.Dispose(); + Console.WriteLine("Server stopped."); + break; + } + + if (!string.IsNullOrEmpty(message)) + { + await _server.BroadcastAsync(message).ConfigureAwait(false); + } + + await Task.Delay(5000).ConfigureAwait(false); + } + } + + private static async Task CreateServerAsync() + { + var config = new Configuration + { + Protocol = ProtocolType.TCP, + Host = "0.0.0.0", + Port = 1111, + UseAesEncryption = false, + MaxConnections = 100000, + AesPassword = "EonaCat.Connections.Password", + //Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("server.pfx", "p@ss"), + EnableHeartbeat = IsHeartBeatEnabled + }; + + _server = new NetworkServer(config); + + // Subscribe to events + _server.OnConnected += (sender, e) => + { + WriteToLog($"Client {e.ClientId} connected from {e.RemoteEndPoint}"); + Console.WriteLine($"New connection from {e.RemoteEndPoint} with Client ID: {e.ClientId}"); + }; + + _server.OnConnectedWithNickname += (sender, e) => WriteToLog($"Client {e.ClientId} connected with nickname: {e.Nickname}"); + + _server.OnDataReceived += async (sender, e) => + { + WriteToLog($"Received from {e.ClientId} ({e.RemoteEndPoint.ToString()}): {(e.IsBinary ? $"{e.Data.Length} bytes" : "a message")}"); + + // Echo back the message + if (e.IsBinary) + { + await _server.SendToClientAsync(e.ClientId, e.Data); + } + else + { + await _server.SendToClientAsync(e.ClientId, e.StringData); + } + }; + + _server.OnDisconnected += (sender, e) => + { + var message = string.Empty; + if (e.Reason == DisconnectReason.LocalClosed) + { + 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 + { + 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); + }; + + _ = _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/BufferSize.cs b/EonaCat.Connections/BufferSize.cs index 96a88dc..91e0a3e 100644 --- a/EonaCat.Connections/BufferSize.cs +++ b/EonaCat.Connections/BufferSize.cs @@ -1,19 +1,13 @@ -// 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. - -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, - } -} +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. + + internal enum BufferSizeMaximum + { + Minimal = 8192, + Medium = 65536, + Large = 262144, + ExtraLarge = 1048576, + } +} \ No newline at end of file diff --git a/EonaCat.Connections/DisconnectReason.cs b/EonaCat.Connections/DisconnectReason.cs index 1e2fb82..c31b99b 100644 --- a/EonaCat.Connections/DisconnectReason.cs +++ b/EonaCat.Connections/DisconnectReason.cs @@ -1,21 +1,21 @@ -// 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. - -namespace EonaCat.Connections -{ - public enum DisconnectReason - { - Unknown, - RemoteClosed, - LocalClosed, - Timeout, - Error, - SSLError, - ServerShutdown, - Reconnect, - ClientRequested, - Forced, - NoPongReceived, - ProtocolError, - } +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, + RemoteClosed, + LocalClosed, + Timeout, + Error, + SSLError, + ServerShutdown, + Reconnect, + ClientRequested, + Forced, + NoPongReceived, + ProtocolError, + } } \ No newline at end of file diff --git a/EonaCat.Connections/EonaCat.Connections.csproj b/EonaCat.Connections/EonaCat.Connections.csproj index 07e20e3..e02a7ed 100644 --- a/EonaCat.Connections/EonaCat.Connections.csproj +++ b/EonaCat.Connections/EonaCat.Connections.csproj @@ -39,9 +39,9 @@ - - - + + + diff --git a/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs b/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs index 66b13b0..eef70a0 100644 --- a/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs +++ b/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs @@ -1,80 +1,80 @@ -using System.Net; -using System.Net.Sockets; - -// 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. - -namespace EonaCat.Connections.EventArguments -{ - public class ConnectionEventArgs : EventArgs - { - public string ClientId { get; set; } - public string Nickname { get; set; } - public IPEndPoint RemoteEndPoint { get; set; } - public DisconnectReason Reason { get; set; } = DisconnectReason.Unknown; - public Exception Exception { get; set; } - public bool HasException => Exception != null; - - public bool IsLocalDisconnect => - Reason == DisconnectReason.LocalClosed - || Reason == DisconnectReason.Timeout - || Reason == DisconnectReason.ServerShutdown - || Reason == DisconnectReason.Reconnect - || Reason == DisconnectReason.ClientRequested - || Reason == DisconnectReason.Forced; - - public bool IsRemoteDisconnect => - Reason == DisconnectReason.RemoteClosed; - - public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname); - public bool HasClientId => !string.IsNullOrWhiteSpace(ClientId); - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - public bool HasRemoteEndPoint => RemoteEndPoint != null; - public bool IsRemoteEndPointIPv4 => RemoteEndPoint?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork; - 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 exception) - { - if (exception == null) - { - return reason; - } - - if (exception is SocketException socketException) - { - switch (socketException.SocketErrorCode) - { - case SocketError.ConnectionReset: - case SocketError.Shutdown: - case SocketError.Disconnecting: - return DisconnectReason.RemoteClosed; - - case SocketError.TimedOut: - return DisconnectReason.Timeout; - - case SocketError.NetworkDown: - case SocketError.NetworkReset: - case SocketError.NetworkUnreachable: - return DisconnectReason.Error; - - default: - return DisconnectReason.Error; - } - } - - if (exception is ObjectDisposedException || exception is InvalidOperationException) - { - return DisconnectReason.LocalClosed; - } - - 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; - } - - return DisconnectReason.Error; - } - } +using System.Net; +using System.Net.Sockets; + +// 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. + +namespace EonaCat.Connections.EventArguments +{ + public class ConnectionEventArgs : EventArgs + { + public string ClientId { get; set; } + public string Nickname { get; set; } + public IPEndPoint RemoteEndPoint { get; set; } + public DisconnectReason Reason { get; set; } = DisconnectReason.Unknown; + public Exception Exception { get; set; } + public bool HasException => Exception != null; + + public bool IsLocalDisconnect => + Reason == DisconnectReason.LocalClosed + || Reason == DisconnectReason.Timeout + || Reason == DisconnectReason.ServerShutdown + || Reason == DisconnectReason.Reconnect + || Reason == DisconnectReason.ClientRequested + || Reason == DisconnectReason.Forced; + + public bool IsRemoteDisconnect => + Reason == DisconnectReason.RemoteClosed; + + public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname); + public bool HasClientId => !string.IsNullOrWhiteSpace(ClientId); + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public bool HasRemoteEndPoint => RemoteEndPoint != null; + public bool IsRemoteEndPointIPv4 => RemoteEndPoint?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork; + 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 exception) + { + if (exception == null) + { + return reason; + } + + if (exception is SocketException socketException) + { + switch (socketException.SocketErrorCode) + { + case SocketError.ConnectionReset: + case SocketError.Shutdown: + case SocketError.Disconnecting: + return DisconnectReason.RemoteClosed; + + case SocketError.TimedOut: + return DisconnectReason.Timeout; + + case SocketError.NetworkDown: + case SocketError.NetworkReset: + case SocketError.NetworkUnreachable: + return DisconnectReason.Error; + + default: + return DisconnectReason.Error; + } + } + + if (exception is ObjectDisposedException || exception is InvalidOperationException) + { + return DisconnectReason.LocalClosed; + } + + 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; + } + + return DisconnectReason.Error; + } + } } \ No newline at end of file diff --git a/EonaCat.Connections/EventArguments/DataReceivedEventArgs.cs b/EonaCat.Connections/EventArguments/DataReceivedEventArgs.cs index 7083ae2..e04730e 100644 --- a/EonaCat.Connections/EventArguments/DataReceivedEventArgs.cs +++ b/EonaCat.Connections/EventArguments/DataReceivedEventArgs.cs @@ -1,19 +1,20 @@ -using System.Net; - -// 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. - -namespace EonaCat.Connections -{ - public class DataReceivedEventArgs : EventArgs - { - public string ClientId { get; internal set; } - public byte[] Data { get; internal set; } - public string StringData { get; internal set; } - public bool IsBinary { get; internal set; } - public DateTime Timestamp { get; internal set; } = DateTime.UtcNow; - public IPEndPoint RemoteEndPoint { get; internal set; } - public string Nickname { get; internal set; } - public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname); - } +using System.Net; +using System.Text; + +// 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. + +namespace EonaCat.Connections +{ + public class DataReceivedEventArgs : EventArgs + { + public string ClientId { get; set; } + public byte[] Data { get; set; } + public string StringData => Data != null ? Encoding.UTF8.GetString(Data) : string.Empty; + public bool IsBinary { get; internal set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public IPEndPoint RemoteEndPoint { get; set; } + public string Nickname { get; set; } + 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 42b9c28..79ef798 100644 --- a/EonaCat.Connections/EventArguments/ErrorEventArgs.cs +++ b/EonaCat.Connections/EventArguments/ErrorEventArgs.cs @@ -1,8 +1,8 @@ -// 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. - -namespace EonaCat.Connections.EventArguments -{ +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; } diff --git a/EonaCat.Connections/EventArguments/IdleClientEventArgs.cs b/EonaCat.Connections/EventArguments/IdleClientEventArgs.cs new file mode 100644 index 0000000..17f3d00 --- /dev/null +++ b/EonaCat.Connections/EventArguments/IdleClientEventArgs.cs @@ -0,0 +1,30 @@ +using System.Net; + +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 IdleClientEventArgs : EventArgs + { + public IdleClientEventArgs() { } + + public IdleClientEventArgs(double idleTimeoutSeconds, NetworkClient networkClient, string message) + { + IdleTimeoutSeconds = idleTimeoutSeconds; + NetworkClient = networkClient; + Message = message; + } + + public double IdleTimeoutSeconds { get; set; } + public NetworkClient? NetworkClient { get; set; } + public string? ClientId { get; set; } + public string? Nickname { get; set; } + public IPEndPoint? RemoteEndPoint { get; set; } + public double IdleTimeSeconds { get; set; } + public bool HasClient => NetworkClient != null; + public bool IsIdle => IdleTimeoutSeconds > 0 || IdleTimeSeconds > 0; + public bool HasMessage => !string.IsNullOrEmpty(Message); + public string? Message { get; set; } + } +} \ No newline at end of file diff --git a/EonaCat.Connections/EventArguments/IdleEventArgs.cs b/EonaCat.Connections/EventArguments/IdleEventArgs.cs new file mode 100644 index 0000000..0828ec8 --- /dev/null +++ b/EonaCat.Connections/EventArguments/IdleEventArgs.cs @@ -0,0 +1,24 @@ +using EonaCat.Connections.Models; + +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 IdleEventArgs : EventArgs + { + public IdleEventArgs(double idleTimeoutSeconds, Connection connection, string message) + { + IdleTimeoutSeconds = idleTimeoutSeconds; + Connection = connection; + Message = message; + } + + public double IdleTimeoutSeconds { get; } + public Connection Connection { get; } + public bool HasConnection => Connection != null; + public bool IsIdle => IdleTimeoutSeconds > 0; + public bool HasMessage => !string.IsNullOrEmpty(Message); + public string Message { get; } + } +} \ No newline at end of file diff --git a/EonaCat.Connections/EventArguments/PingEventArgs.cs b/EonaCat.Connections/EventArguments/PingEventArgs.cs index d38e79f..3bbe66e 100644 --- a/EonaCat.Connections/EventArguments/PingEventArgs.cs +++ b/EonaCat.Connections/EventArguments/PingEventArgs.cs @@ -1,15 +1,15 @@ -using System.Net; - -// 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. - -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; } - } +using System.Net; + +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 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 a6a13b0..c7f93d2 100644 --- a/EonaCat.Connections/Helpers/AesKeyExchange.cs +++ b/EonaCat.Connections/Helpers/AesKeyExchange.cs @@ -1,242 +1,240 @@ -using System.Security.Cryptography; -using System.Text; - -// 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. - -namespace EonaCat.Connections.Helpers -{ - public static class AesKeyExchange - { - // 256-bit salt - private const int _saltSize = 32; - - // 128-bit IV - private const int _ivSize = 16; - - // 256-bit AES key - private const int _aesKeySize = 32; - - // 256-bit HMAC key (key confirmation) - private const int _hmacKeySize = 32; - - // PBKDF2 iterations - private const int _iterations = 800_000; - - private static readonly byte[] KeyConfirmationLabel = Encoding.UTF8.GetBytes("KEYCONFIRMATION"); - - public static async Task EncryptDataAsync(byte[] buffer, int bytesToSend, Aes aes) - { - using (var encryptor = aes.CreateEncryptor()) - { - 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[] buffer, int bytesToSend, Aes aes) - { - using (var decryptor = aes.CreateDecryptor()) - { - byte[] decrypted = await Task.Run(() => decryptor.TransformFinalBlock(buffer, 0, bytesToSend)).ConfigureAwait(false); - - Buffer.BlockCopy(decrypted, 0, buffer, 0, decrypted.Length); - - return decrypted.Length; - } - } - - public static async Task SendAesKeyAsync(Stream stream, Aes aes, string password) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - if (aes == null) - { - throw new ArgumentNullException(nameof(aes)); - } - - if (string.IsNullOrWhiteSpace(password)) - { - throw new ArgumentException("Password/PSK required", nameof(password)); - } - - var salt = RandomBytes(_saltSize); - var iv = RandomBytes(_ivSize); - - // Derive AES key and HMAC key (for key confirmation) - var keyMaterial = DeriveKey(password, salt, _aesKeySize + _hmacKeySize); - var aesKey = new byte[_aesKeySize]; - var hmacKey = new byte[_hmacKeySize]; - Buffer.BlockCopy(keyMaterial, 0, aesKey, 0, _aesKeySize); - Buffer.BlockCopy(keyMaterial, _aesKeySize, hmacKey, 0, _hmacKeySize); - - // Compute key confirmation HMAC = HMAC(hmacKey, "KEYCONFIRM" || salt || iv) - byte[] keyConfirm; - using (var h = 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); - keyConfirm = h.Hash; - } - - // Send: salt, iv, keyConfirm (each length-prefixed 4-byte big-endian) - await WriteWithLengthAsync(stream, salt).ConfigureAwait(false); - await WriteWithLengthAsync(stream, iv).ConfigureAwait(false); - await WriteWithLengthAsync(stream, keyConfirm).ConfigureAwait(false); - await stream.FlushAsync().ConfigureAwait(false); - - // Configure AES and return - aes.KeySize = 256; - aes.Mode = CipherMode.CBC; - aes.Padding = PaddingMode.PKCS7; - aes.Key = aesKey; - aes.IV = iv; - - return aes; - } - - public static async Task ReceiveAesKeyAsync(Stream stream, string password) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - if (string.IsNullOrWhiteSpace(password)) - { - throw new ArgumentException("Password/PSK required", nameof(password)); - } - - var salt = await ReadWithLengthAsync(stream).ConfigureAwait(false); - var iv = await ReadWithLengthAsync(stream).ConfigureAwait(false); - var keyConfirm = await ReadWithLengthAsync(stream).ConfigureAwait(false); - - if (salt == null || salt.Length != _saltSize) - { - throw new InvalidOperationException("Invalid salt length"); - } - - if (iv == null || iv.Length != _ivSize) - { - throw new InvalidOperationException("Invalid IV length"); - } - - var keyMaterial = DeriveKey(password, salt, _aesKeySize + _hmacKeySize); - var aesKey = new byte[_aesKeySize]; - var hmacKey = new byte[_hmacKeySize]; - Buffer.BlockCopy(keyMaterial, 0, aesKey, 0, _aesKeySize); - Buffer.BlockCopy(keyMaterial, _aesKeySize, hmacKey, 0, _hmacKeySize); - - byte[] expected; - using (var hmac = new HMACSHA256(hmacKey)) - { - 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)) - { - throw new CryptographicException("Key confirmation failed - wrong password or tampered data"); - } - - var aes = Aes.Create(); - aes.KeySize = 256; - aes.Mode = CipherMode.CBC; - aes.Padding = PaddingMode.PKCS7; - aes.Key = aesKey; - aes.IV = iv; - - return aes; - } - - private static async Task WriteWithLengthAsync(Stream stream, byte[] data) - { - var byteLength = BitConverter.GetBytes(data.Length); - if (BitConverter.IsLittleEndian) - { - Array.Reverse(byteLength); - } - - await stream.WriteAsync(byteLength, 0, 4).ConfigureAwait(false); - await stream.WriteAsync(data, 0, data.Length).ConfigureAwait(false); - } - - private static async Task ReadWithLengthAsync(Stream stream) - { - var bufferLength = new byte[4]; - await ReadExactlyAsync(stream, bufferLength, 0, 4).ConfigureAwait(false); - if (BitConverter.IsLittleEndian) - { - Array.Reverse(bufferLength); - } - - int length = BitConverter.ToInt32(bufferLength, 0); - if (length < 0 || length > 10_000_000) - { - throw new InvalidOperationException("Invalid length"); - } - - var buffer = new byte[length]; - await ReadExactlyAsync(stream, buffer, 0, length).ConfigureAwait(false); - return buffer; - } - - private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, int offset, int count) - { - int total = 0; - while (total < count) - { - int read = await stream.ReadAsync(buffer, offset + total, count - total).ConfigureAwait(false); - if (read == 0) - { - throw new EndOfStreamException("Stream ended prematurely"); - } - - total += read; - } - } - - private static byte[] DeriveKey(string password, byte[] salt, int size) - { - using (var pbkdf2 = new Rfc2898DeriveBytes(password, salt, _iterations, HashAlgorithmName.SHA256)) - { - return pbkdf2.GetBytes(size); - } - } - - private static byte[] RandomBytes(int total) - { - var bytes = new byte[total]; - using (var random = RandomNumberGenerator.Create()) - { - random.GetBytes(bytes); - } - - return bytes; - } - - private static bool FixedTimeEquals(byte[] firstByteArray, byte[] secondByteArray) - { - if (firstByteArray == null || secondByteArray == null || firstByteArray.Length != secondByteArray.Length) - { - return false; - } - - int difference = 0; - for (int i = 0; i < firstByteArray.Length; i++) - { - difference |= firstByteArray[i] ^ secondByteArray[i]; - } - - return difference == 0; - } - } +using System.Security.Cryptography; +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 + private const int _saltSize = 32; + + // 128-bit IV + private const int _ivSize = 16; + + // 256-bit AES key + private const int _aesKeySize = 32; + + // 256-bit HMAC key (key confirmation) + private const int _hmacKeySize = 32; + + // PBKDF2 iterations + private const int _iterations = 800_000; + + private static readonly byte[] KeyConfirmationLabel = Encoding.UTF8.GetBytes("KEYCONFIRMATION"); + + // AES block size for PKCS7 padding + public static int MaxEncryptionOverhead => 16; + + public static Task EncryptDataAsync(byte[] data, int length, Aes aes) + { + using var encryptor = aes.CreateEncryptor(); + byte[] encrypted = encryptor.TransformFinalBlock(data, 0, length); + return Task.FromResult(encrypted); + } + + public static Task DecryptDataAsync(byte[] data, int length, Aes aes) + { + using var decryptor = aes.CreateDecryptor(); + byte[] decrypted = decryptor.TransformFinalBlock(data, 0, length); + return Task.FromResult(decrypted); + } + + public static async Task SendAesKeyAsync(Stream stream, Aes aes, string password) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (aes == null) + { + throw new ArgumentNullException(nameof(aes)); + } + + if (string.IsNullOrWhiteSpace(password)) + { + throw new ArgumentException("Password/PSK required", nameof(password)); + } + + var salt = RandomBytes(_saltSize); + var iv = RandomBytes(_ivSize); + + // Derive AES key and HMAC key (for key confirmation) + var keyMaterial = DeriveKey(password, salt, _aesKeySize + _hmacKeySize); + var aesKey = new byte[_aesKeySize]; + var hmacKey = new byte[_hmacKeySize]; + Buffer.BlockCopy(keyMaterial, 0, aesKey, 0, _aesKeySize); + Buffer.BlockCopy(keyMaterial, _aesKeySize, hmacKey, 0, _hmacKeySize); + + // Compute key confirmation HMAC = HMAC(hmacKey, "KEYCONFIRM" || salt || iv) + byte[] keyConfirm; + using (var h = 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); + keyConfirm = h.Hash; + } + + // Send: salt, iv, keyConfirm (each length-prefixed 4-byte big-endian) + await WriteWithLengthAsync(stream, salt).ConfigureAwait(false); + await WriteWithLengthAsync(stream, iv).ConfigureAwait(false); + await WriteWithLengthAsync(stream, keyConfirm).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + + // Configure AES and return + aes.KeySize = 256; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.Key = aesKey; + aes.IV = iv; + + return aes; + } + + public static async Task ReceiveAesKeyAsync(Stream stream, string password) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (string.IsNullOrWhiteSpace(password)) + { + throw new ArgumentException("Password/PSK required", nameof(password)); + } + + var salt = await ReadWithLengthAsync(stream).ConfigureAwait(false); + var iv = await ReadWithLengthAsync(stream).ConfigureAwait(false); + var keyConfirm = await ReadWithLengthAsync(stream).ConfigureAwait(false); + + if (salt == null || salt.Length != _saltSize) + { + throw new InvalidOperationException("Invalid salt length"); + } + + if (iv == null || iv.Length != _ivSize) + { + throw new InvalidOperationException("Invalid IV length"); + } + + var keyMaterial = DeriveKey(password, salt, _aesKeySize + _hmacKeySize); + var aesKey = new byte[_aesKeySize]; + var hmacKey = new byte[_hmacKeySize]; + Buffer.BlockCopy(keyMaterial, 0, aesKey, 0, _aesKeySize); + Buffer.BlockCopy(keyMaterial, _aesKeySize, hmacKey, 0, _hmacKeySize); + + byte[] expected; + using (var hmac = new HMACSHA256(hmacKey)) + { + 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)) + { + throw new CryptographicException("Key confirmation failed - wrong password or tampered data"); + } + + var aes = Aes.Create(); + aes.KeySize = 256; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.Key = aesKey; + aes.IV = iv; + + return aes; + } + + private static async Task WriteWithLengthAsync(Stream stream, byte[] data) + { + var byteLength = BitConverter.GetBytes(data.Length); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(byteLength); + } + + await stream.WriteAsync(byteLength, 0, 4).ConfigureAwait(false); + await stream.WriteAsync(data, 0, data.Length).ConfigureAwait(false); + } + + private static async Task ReadWithLengthAsync(Stream stream) + { + var bufferLength = new byte[4]; + await ReadExactlyAsync(stream, bufferLength, 0, 4).ConfigureAwait(false); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(bufferLength); + } + + int length = BitConverter.ToInt32(bufferLength, 0); + if (length < 0 || length > 10_000_000) + { + throw new InvalidOperationException("Invalid length"); + } + + var buffer = new byte[length]; + await ReadExactlyAsync(stream, buffer, 0, length).ConfigureAwait(false); + return buffer; + } + + private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, int offset, int count) + { + int total = 0; + while (total < count) + { + int read = await stream.ReadAsync(buffer, offset + total, count - total).ConfigureAwait(false); + if (read == 0) + { + throw new EndOfStreamException("Stream ended prematurely"); + } + + total += read; + } + } + + private static byte[] DeriveKey(string password, byte[] salt, int size) + { + using (var pbkdf2 = new Rfc2898DeriveBytes(password, salt, _iterations, HashAlgorithmName.SHA256)) + { + return pbkdf2.GetBytes(size); + } + } + + private static byte[] RandomBytes(int total) + { + var bytes = new byte[total]; + using (var random = RandomNumberGenerator.Create()) + { + random.GetBytes(bytes); + } + + return bytes; + } + + private static bool FixedTimeEquals(byte[] firstByteArray, byte[] secondByteArray) + { + if (firstByteArray == null || secondByteArray == null || firstByteArray.Length != secondByteArray.Length) + { + return false; + } + +#if NET5_0_OR_GREATER + return CryptographicOperations.FixedTimeEquals(firstByteArray, secondByteArray); +#else + int difference = 0; + for (int i = 0; i < firstByteArray.Length; i++) + { + difference |= firstByteArray[i] ^ secondByteArray[i]; + } + + return difference == 0; +#endif + } + } } \ No newline at end of file diff --git a/EonaCat.Connections/Helpers/HealthApiServer.cs b/EonaCat.Connections/Helpers/HealthApiServer.cs new file mode 100644 index 0000000..a0f015b --- /dev/null +++ b/EonaCat.Connections/Helpers/HealthApiServer.cs @@ -0,0 +1,325 @@ +using System.Net; +using System.Net.Sockets; +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. + + /// + /// A lightweight HTTP server that exposes health and status information via a REST API. + /// Listens on a configurable port (use 0 for a random available port). + /// + public class HealthApiServer : IDisposable + { + private TcpListener _listener; + private CancellationTokenSource _cts; + private Task _listenTask; + private volatile bool _running; + private readonly Func _getHealthJson; + private readonly Func _getStatusJson; + + /// + /// Gets the actual port the server is listening on. + /// Returns 0 if the server is not started. + /// + public int Port { get; private set; } + + /// + /// Gets whether the server is currently running. + /// + public bool IsRunning => _running; + + /// + /// Creates a new HealthApiServer. + /// + /// Callback that returns the JSON for the /health endpoint. + /// Callback that returns the JSON for the /status endpoint. + public HealthApiServer(Func getHealthJson, Func getStatusJson) + { + _getHealthJson = getHealthJson ?? throw new ArgumentNullException(nameof(getHealthJson)); + _getStatusJson = getStatusJson ?? throw new ArgumentNullException(nameof(getStatusJson)); + } + + /// + /// Starts the HTTP health API server. + /// + /// Port to listen on. Use 0 for a random available port. + /// IP address to bind to. Use IPAddress.Any for Docker/container environments. Defaults to IPAddress.Loopback. + public void Start(int port = 0, IPAddress bindAddress = null) + { + if (_running) + { + return; + } + + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + _running = true; + + _listener = new TcpListener(bindAddress ?? IPAddress.Loopback, port); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + + var token = _cts.Token; + _listenTask = Task.Run(async () => await AcceptLoopAsync(token).ConfigureAwait(false), token); + } + + /// + /// Stops the HTTP health API server. + /// + public void Stop() + { + if (!_running) + { + return; + } + + _running = false; + _cts?.Cancel(); + + try + { + _listener?.Stop(); + } + catch + { + // Swallow + } + + try + { + _listenTask?.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + } + + Port = 0; + } + + private async Task AcceptLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + TcpClient client = null; + try + { + client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); + _ = Task.Run(() => HandleRequestAsync(client), token); + } + catch (ObjectDisposedException) + { + break; + } + catch (SocketException) + { + break; + } + catch + { + // Swallow individual connection errors to keep the loop alive + } + } + } + + private async Task HandleRequestAsync(TcpClient client) + { + try + { + using (client) + { + client.ReceiveTimeout = 5000; + client.SendTimeout = 5000; + + using var stream = client.GetStream(); + var requestLine = await ReadRequestLineAsync(stream).ConfigureAwait(false); + + if (string.IsNullOrEmpty(requestLine)) + { + await WriteResponseAsync(stream, 400, "Bad Request", "text/plain", "Bad Request").ConfigureAwait(false); + return; + } + + var parts = requestLine.Split(' '); + if (parts.Length < 2) + { + await WriteResponseAsync(stream, 400, "Bad Request", "text/plain", "Bad Request").ConfigureAwait(false); + return; + } + + var method = parts[0].ToUpperInvariant(); + var path = parts[1].Split('?')[0].TrimEnd('/').ToLowerInvariant(); + + if (method != "GET") + { + await WriteResponseAsync(stream, 405, "Method Not Allowed", "text/plain", "Method Not Allowed").ConfigureAwait(false); + return; + } + + // Drain remaining headers + await DrainHeadersAsync(stream).ConfigureAwait(false); + + switch (path) + { + case "" or "/": + var indexJson = "{\"endpoints\":[\"/health\",\"/status\"]}"; + await WriteResponseAsync(stream, 200, "OK", "application/json", indexJson).ConfigureAwait(false); + break; + + case "/health": + var healthJson = SafeGetJson(_getHealthJson); + await WriteResponseAsync(stream, 200, "OK", "application/json", healthJson).ConfigureAwait(false); + break; + + case "/status": + var statusJson = SafeGetJson(_getStatusJson); + await WriteResponseAsync(stream, 200, "OK", "application/json", statusJson).ConfigureAwait(false); + break; + + default: + await WriteResponseAsync(stream, 404, "Not Found", "text/plain", "Not Found").ConfigureAwait(false); + break; + } + } + } + catch + { + // Swallow individual request errors + } + } + + private static string SafeGetJson(Func getter) + { + try + { + return getter() ?? "{}"; + } + catch (Exception ex) + { + return "{\"error\":" + JsonEscape(ex.Message) + "}"; + } + } + + private static async Task ReadRequestLineAsync(NetworkStream stream) + { + var sb = new StringBuilder(); + var buffer = new byte[1]; + var prev = (byte)0; + + while (sb.Length < 8192) + { + int read = await stream.ReadAsync(buffer, 0, 1).ConfigureAwait(false); + if (read == 0) + { + break; + } + + var b = buffer[0]; + if (b == (byte)'\n') + { + break; + } + + if (b != (byte)'\r') + { + sb.Append((char)b); + } + + prev = b; + } + + return sb.ToString(); + } + + private static async Task DrainHeadersAsync(NetworkStream stream) + { + var sb = new StringBuilder(); + var buffer = new byte[1]; + int consecutiveNewlines = 0; + + while (consecutiveNewlines < 2) + { + int read = await stream.ReadAsync(buffer, 0, 1).ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (buffer[0] == (byte)'\n') + { + consecutiveNewlines++; + } + else if (buffer[0] != (byte)'\r') + { + consecutiveNewlines = 0; + } + } + } + + private static async Task WriteResponseAsync(NetworkStream stream, int statusCode, string statusText, string contentType, string body) + { + var bodyBytes = Encoding.UTF8.GetBytes(body); + var header = $"HTTP/1.1 {statusCode} {statusText}\r\n" + + $"Content-Type: {contentType}; charset=utf-8\r\n" + + $"Content-Length: {bodyBytes.Length}\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + "Connection: close\r\n" + + "X-Content-Type-Options: nosniff\r\n" + + "Cache-Control: no-store\r\n" + + "\r\n"; + + var headerBytes = Encoding.ASCII.GetBytes(header); + await stream.WriteAsync(headerBytes, 0, headerBytes.Length).ConfigureAwait(false); + await stream.WriteAsync(bodyBytes, 0, bodyBytes.Length).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } + + /// + /// Escapes a string value for safe embedding in JSON output. + /// + public static string JsonEscape(string value) + { + if (value == null) + { + return "null"; + } + + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < ' ') + { + sb.Append("\\u"); + sb.Append(((int)c).ToString("x4")); + } + else + { + sb.Append(c); + } + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + + public void Dispose() + { + Stop(); + _cts?.Dispose(); + } + } +} diff --git a/EonaCat.Connections/Helpers/NetworkMonitor.cs b/EonaCat.Connections/Helpers/NetworkMonitor.cs new file mode 100644 index 0000000..f5fc912 --- /dev/null +++ b/EonaCat.Connections/Helpers/NetworkMonitor.cs @@ -0,0 +1,652 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.NetworkInformation; +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 class NetworkMonitor : IDisposable + { + private readonly string _targetHost; + private readonly int _pingIntervalMs; + private readonly int _maxHistory; + private readonly ConcurrentQueue _samples = new ConcurrentQueue(); + private CancellationTokenSource _cts; + private Task _monitorTask; + private volatile bool _isRunning; + + private CancellationTokenSource _autoReportCts; + private Task _autoReportTask; + private volatile bool _autoReportRunning; + + public event EventHandler OnOutageDetected; + public event EventHandler OnOutageRecovered; + public event EventHandler OnSampleRecorded; + + public bool IsRunning => _isRunning; + public string TargetHost => _targetHost; + + /// + /// Gets whether automatic HTML report generation is currently running. + /// + public bool IsAutoHtmlReportRunning => _autoReportRunning; + + public NetworkMonitor(string targetHost = "127.0.0.1", int pingIntervalMs = 5000, int maxHistory = 1000) + { + _targetHost = targetHost; + _pingIntervalMs = Math.Max(1000, pingIntervalMs); + _maxHistory = Math.Max(10, maxHistory); + } + + public void Start() + { + if (_isRunning) + { + return; + } + + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + _isRunning = true; + _monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token)); + } + + public void Stop() + { + _isRunning = false; + _cts?.Cancel(); + + try + { + _monitorTask?.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + } + } + + private async Task MonitorLoopAsync(CancellationToken token) + { + bool wasReachable = true; + DateTime? outageStartedAt = null; + + while (!token.IsCancellationRequested) + { + var sample = await CollectSampleAsync().ConfigureAwait(false); + RecordSample(sample); + + OnSampleRecorded?.Invoke(this, sample); + + if (!sample.IsReachable && wasReachable) + { + outageStartedAt = sample.Timestamp; + OnOutageDetected?.Invoke(this, new NetworkOutageEventArgs + { + TargetHost = _targetHost, + OutageStartedAt = sample.Timestamp, + Sample = sample + }); + } + else if (sample.IsReachable && !wasReachable && outageStartedAt.HasValue) + { + OnOutageRecovered?.Invoke(this, new NetworkOutageEventArgs + { + TargetHost = _targetHost, + OutageStartedAt = outageStartedAt.Value, + OutageEndedAt = sample.Timestamp, + OutageDuration = sample.Timestamp - outageStartedAt.Value, + Sample = sample + }); + outageStartedAt = null; + } + + wasReachable = sample.IsReachable; + + try + { + await Task.Delay(_pingIntervalMs, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task CollectSampleAsync() + { + var sample = new NetworkSample + { + Timestamp = DateTime.UtcNow, + TargetHost = _targetHost + }; + + try + { + using (var ping = new Ping()) + { + var sw = Stopwatch.StartNew(); + var reply = await ping.SendPingAsync(_targetHost, 5000).ConfigureAwait(false); + sw.Stop(); + + sample.RoundTripTimeMs = reply.RoundtripTime; + sample.MeasuredLatencyMs = sw.ElapsedMilliseconds; + sample.IsReachable = reply.Status == IPStatus.Success; + sample.PingStatus = reply.Status; + } + } + catch (PingException ex) + { + sample.IsReachable = false; + sample.Error = ex.InnerException?.Message ?? ex.Message; + } + catch (Exception ex) + { + sample.IsReachable = false; + sample.Error = ex.Message; + } + + try + { + var interfaces = NetworkInterface.GetAllNetworkInterfaces(); + long totalBytesReceived = 0; + long totalBytesSent = 0; + + foreach (var ni in interfaces) + { + if (ni.OperationalStatus != OperationalStatus.Up) + { + continue; + } + + if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + var stats = ni.GetIPv4Statistics(); + totalBytesReceived += stats.BytesReceived; + totalBytesSent += stats.BytesSent; + } + + sample.SystemBytesReceived = totalBytesReceived; + sample.SystemBytesSent = totalBytesSent; + } + catch + { + // Network interface stats may not be available on all platforms + } + + return sample; + } + + private void RecordSample(NetworkSample sample) + { + _samples.Enqueue(sample); + + while (_samples.Count > _maxHistory) + { + _samples.TryDequeue(out _); + } + } + + public IReadOnlyList GetSamples() + { + return _samples.ToArray(); + } + + public IReadOnlyList GetRecentSamples(int count) + { + var all = _samples.ToArray(); + int skip = Math.Max(0, all.Length - count); + var result = new NetworkSample[Math.Min(count, all.Length)]; + Array.Copy(all, skip, result, 0, result.Length); + return result; + } + + public NetworkHealthReport GetHealthReport() + { + var samples = _samples.ToArray(); + var report = new NetworkHealthReport + { + TargetHost = _targetHost, + GeneratedAt = DateTime.UtcNow, + TotalSamples = samples.Length + }; + + if (samples.Length == 0) + { + return report; + } + + var reachable = new List(); + var unreachable = new List(); + + foreach (var s in samples) + { + if (s.IsReachable) + { + reachable.Add(s); + } + else + { + unreachable.Add(s); + } + } + + report.SuccessfulPings = reachable.Count; + report.FailedPings = unreachable.Count; + report.UptimePercentage = samples.Length > 0 + ? (double)reachable.Count / samples.Length * 100.0 + : 0; + + if (reachable.Count > 0) + { + long totalRtt = 0; + long minRtt = long.MaxValue; + long maxRtt = long.MinValue; + + foreach (var s in reachable) + { + totalRtt += s.RoundTripTimeMs; + if (s.RoundTripTimeMs < minRtt) + { + minRtt = s.RoundTripTimeMs; + } + if (s.RoundTripTimeMs > maxRtt) + { + maxRtt = s.RoundTripTimeMs; + } + } + + report.AverageLatencyMs = (double)totalRtt / reachable.Count; + report.MinLatencyMs = minRtt; + report.MaxLatencyMs = maxRtt; + + // Calculate jitter (standard deviation of latency) + double sumSquaredDiff = 0; + foreach (var s in reachable) + { + var diff = s.RoundTripTimeMs - report.AverageLatencyMs; + sumSquaredDiff += diff * diff; + } + + report.JitterMs = Math.Sqrt(sumSquaredDiff / reachable.Count); + } + + // Detect outage windows + bool inOutage = false; + DateTime outageStart = DateTime.MinValue; + + foreach (var s in samples) + { + if (!s.IsReachable && !inOutage) + { + inOutage = true; + outageStart = s.Timestamp; + } + else if (s.IsReachable && inOutage) + { + inOutage = false; + report.OutageWindows.Add(new OutageWindow + { + Start = outageStart, + End = s.Timestamp, + Duration = s.Timestamp - outageStart + }); + } + } + + if (inOutage) + { + report.OutageWindows.Add(new OutageWindow + { + Start = outageStart, + End = DateTime.UtcNow, + Duration = DateTime.UtcNow - outageStart, + IsOngoing = true + }); + } + + // Bandwidth estimate between last two samples + if (samples.Length >= 2) + { + var prev = samples[samples.Length - 2]; + var curr = samples[samples.Length - 1]; + var elapsed = (curr.Timestamp - prev.Timestamp).TotalSeconds; + + if (elapsed > 0 && prev.SystemBytesReceived > 0 && curr.SystemBytesReceived > 0) + { + report.CurrentReceiveBytesPerSecond = (curr.SystemBytesReceived - prev.SystemBytesReceived) / elapsed; + report.CurrentSendBytesPerSecond = (curr.SystemBytesSent - prev.SystemBytesSent) / elapsed; + } + } + + report.IsStable = report.UptimePercentage >= 99.0 + && report.JitterMs < 50 + && report.OutageWindows.Count(o => o.IsOngoing) == 0; + + return report; + } + + public void Dispose() + { + Stop(); + StopAutoHtmlReport(); + _cts?.Dispose(); + _autoReportCts?.Dispose(); + } + + /// + /// Starts periodic automatic generation of the network health HTML report. + /// + /// Directory where the HTML file is written. + /// Interval in seconds between report generations. + /// The HTML file name. + public void StartAutoHtmlReport(string outputDirectory, int intervalSeconds = 60, string fileName = "status-network.html") + { + if (_autoReportRunning) + { + return; + } + + _autoReportCts?.Dispose(); + _autoReportCts = new CancellationTokenSource(); + _autoReportRunning = true; + + var interval = TimeSpan.FromSeconds(Math.Max(5, intervalSeconds)); + var token = _autoReportCts.Token; + + _autoReportTask = Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + if (!Directory.Exists(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + var report = GetHealthReport(); + var html = report.GenerateHtmlReport(); + var filePath = Path.Combine(outputDirectory, fileName); + File.WriteAllText(filePath, html, Encoding.UTF8); + } + catch + { + // Swallow to keep the loop alive + } + + try + { + await Task.Delay(interval, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + }, token); + } + + /// + /// Stops the automatic HTML report generation. + /// + public void StopAutoHtmlReport() + { + if (!_autoReportRunning) + { + return; + } + + _autoReportRunning = false; + _autoReportCts?.Cancel(); + + try + { + _autoReportTask?.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + } + } + } + + public class NetworkSample + { + public DateTime Timestamp { get; set; } + public string TargetHost { get; set; } + public bool IsReachable { get; set; } + public long RoundTripTimeMs { get; set; } + public long MeasuredLatencyMs { get; set; } + public IPStatus PingStatus { get; set; } + public string Error { get; set; } + public long SystemBytesReceived { get; set; } + public long SystemBytesSent { get; set; } + } + + public class NetworkOutageEventArgs : EventArgs + { + public string TargetHost { get; set; } + public DateTime OutageStartedAt { get; set; } + public DateTime? OutageEndedAt { get; set; } + public TimeSpan? OutageDuration { get; set; } + public NetworkSample Sample { get; set; } + } + + public class OutageWindow + { + public DateTime Start { get; set; } + public DateTime End { get; set; } + public TimeSpan Duration { get; set; } + public bool IsOngoing { get; set; } + } + + public class NetworkHealthReport + { + public string TargetHost { get; set; } + public DateTime GeneratedAt { get; set; } + public int TotalSamples { get; set; } + public int SuccessfulPings { get; set; } + public int FailedPings { get; set; } + public double UptimePercentage { get; set; } + public double AverageLatencyMs { get; set; } + public long MinLatencyMs { get; set; } + public long MaxLatencyMs { get; set; } + public double JitterMs { get; set; } + public double CurrentReceiveBytesPerSecond { get; set; } + public double CurrentSendBytesPerSecond { get; set; } + public bool IsStable { get; set; } + public List OutageWindows { get; set; } = new List(); + + public string GetSummary() + { + var sb = new StringBuilder(); + sb.AppendLine($"=== Network Health Report ==="); + sb.AppendLine($"Target: {TargetHost}"); + sb.AppendLine($"Generated: {GeneratedAt:O}"); + sb.AppendLine($"Samples: {TotalSamples}"); + sb.AppendLine($"Success: {SuccessfulPings} / Failed: {FailedPings}"); + sb.AppendLine($"Uptime: {UptimePercentage:F2}%"); + sb.AppendLine($"Latency (avg): {AverageLatencyMs:F1} ms"); + sb.AppendLine($"Latency (min): {MinLatencyMs} ms / (max): {MaxLatencyMs} ms"); + sb.AppendLine($"Jitter: {JitterMs:F1} ms"); + sb.AppendLine($"Recv BW: {CurrentReceiveBytesPerSecond:F0} B/s"); + sb.AppendLine($"Send BW: {CurrentSendBytesPerSecond:F0} B/s"); + sb.AppendLine($"Stable: {(IsStable ? "Yes" : "No")}"); + sb.AppendLine($"Outages: {OutageWindows.Count}"); + + foreach (var o in OutageWindows) + { + var status = o.IsOngoing ? " (ONGOING)" : string.Empty; + sb.AppendLine($" {o.Start:HH:mm:ss} - {o.End:HH:mm:ss} ({o.Duration.TotalSeconds:F0}s){status}"); + } + + return sb.ToString(); + } + + public string GenerateHtmlReport(string title = "Network Health Report") + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{HtmlEncode(title)}"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine($"

{HtmlEncode(title)}

"); + + // Status banner + if (TotalSamples > 0) + { + var bannerClass = IsStable ? "status-stable" : "status-unstable"; + var bannerText = IsStable ? "✔ Network is Stable" : "⚠ Network is Unstable"; + sb.AppendLine($"
{bannerText}
"); + } + + // Summary cards + var uptimeClass = UptimePercentage >= 99 ? "ok" : UptimePercentage >= 95 ? "warn" : "error"; + var latencyClass = AverageLatencyMs < 50 ? "ok" : AverageLatencyMs < 150 ? "warn" : "error"; + var jitterClass = JitterMs < 20 ? "ok" : JitterMs < 50 ? "warn" : "error"; + + sb.AppendLine("
"); + sb.AppendLine($"
Target
{HtmlEncode(TargetHost)}
"); + sb.AppendLine($"
Samples
{TotalSamples}
"); + sb.AppendLine($"
Uptime
{UptimePercentage:F1}%
"); + sb.AppendLine($"
Avg Latency
{AverageLatencyMs:F1} ms
"); + sb.AppendLine($"
Jitter
{JitterMs:F1} ms
"); + sb.AppendLine($"
0 ? "error" : "ok")}\">
Failed Pings
{FailedPings}
"); + sb.AppendLine("
"); + + // Uptime progress bar + sb.AppendLine("

Uptime

"); + var barColor = UptimePercentage >= 99 ? "#27ae60" : UptimePercentage >= 95 ? "#f39c12" : "#e74c3c"; + var barWidth = Math.Max(0, Math.Min(100, UptimePercentage)); + sb.AppendLine($"
{UptimePercentage:F2}%
"); + + // Latency / Bandwidth + sb.AppendLine("

Performance

"); + sb.AppendLine("
"); + sb.AppendLine($"
Min Latency
{MinLatencyMs} ms
"); + sb.AppendLine($"
Max Latency
{MaxLatencyMs} ms
"); + sb.AppendLine($"
Recv Bandwidth
{FormatBytes(CurrentReceiveBytesPerSecond)}/s
"); + sb.AppendLine($"
Send Bandwidth
{FormatBytes(CurrentSendBytesPerSecond)}/s
"); + sb.AppendLine("
"); + + // Outage windows + sb.AppendLine("

Outage History

"); + if (OutageWindows.Count == 0) + { + sb.AppendLine("
"); + sb.AppendLine("
"); + sb.AppendLine("

No outages detected.

"); + sb.AppendLine("
"); + } + else + { + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + for (int i = 0; i < OutageWindows.Count; i++) + { + var o = OutageWindows[i]; + var rowClass = o.IsOngoing ? "ongoing" : ""; + var statusText = o.IsOngoing + ? "ONGOING" + : "Recovered"; + + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine(""); + } + + sb.AppendLine(""); + sb.AppendLine("
#Start (UTC)End (UTC)DurationStatus
{i + 1}{o.Start:yyyy-MM-dd HH:mm:ss}{(o.IsOngoing ? "-" : o.End.ToString("yyyy-MM-dd HH:mm:ss"))}{o.Duration.TotalSeconds:F0}s{statusText}
"); + } + + sb.AppendLine($"
Generated at {GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC — EonaCat.Connections
"); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + + return sb.ToString(); + } + + public void SaveHtmlReport(string filePath, string title = "Network Health Report") + { + var html = GenerateHtmlReport(title); + File.WriteAllText(filePath, html, Encoding.UTF8); + } + + private static string FormatBytes(double bytes) + { + if (bytes >= 1_073_741_824) + { + return $"{bytes / 1_073_741_824:F2} GB"; + } + if (bytes >= 1_048_576) + { + return $"{bytes / 1_048_576:F2} MB"; + } + if (bytes >= 1024) + { + return $"{bytes / 1024:F2} KB"; + } + return $"{bytes:F0} B"; + } + + private static string HtmlEncode(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); + } + } +} diff --git a/EonaCat.Connections/Helpers/SslHandshakeDiagnostics.cs b/EonaCat.Connections/Helpers/SslHandshakeDiagnostics.cs new file mode 100644 index 0000000..7eec5d7 --- /dev/null +++ b/EonaCat.Connections/Helpers/SslHandshakeDiagnostics.cs @@ -0,0 +1,263 @@ +using System; +using System.Diagnostics; +using System.Net.Security; + +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. + + /// + /// Helper class for tracking and diagnosing SSL/TLS handshake performance and errors. + /// + public class SslHandshakeDiagnostics + { + private readonly SslMetrics _metrics = new(); + private readonly Stopwatch _totalStopwatch = new(); + private Stopwatch? _stageStopwatch; + + /// + /// Gets the current metrics object. + /// + public SslMetrics Metrics => _metrics; + + /// + /// Starts tracking a new SSL handshake attempt. + /// + public void StartHandshake(string remoteEndPoint, bool mutualAuthRequired) + { + _totalStopwatch.Restart(); + _metrics.StartTime = DateTime.UtcNow; + _metrics.RemoteEndPoint = remoteEndPoint; + _metrics.MutualAuthenticationRequired = mutualAuthRequired; + _metrics.AttemptCount++; + _metrics.IsSuccessful = false; + _metrics.FailureReason = null; + } + + /// + /// Marks the start of a specific SSL stage (e.g., AuthenticateAsServer). + /// + public void StartStage(string stageName) + { + _stageStopwatch = Stopwatch.StartNew(); + } + + /// + /// Marks the end of a specific SSL stage and records the duration. + /// + public void EndStage(string stageName) + { + if (_stageStopwatch == null) + { + return; + } + + _stageStopwatch.Stop(); + var duration = _stageStopwatch.Elapsed; + + switch (stageName.ToLower()) + { + case "serverauth": + case "authenticateasserver": + _metrics.ServerAuthenticationDuration = duration; + break; + case "clientauth": + case "authenticateasclient": + _metrics.ClientAuthenticationDuration = duration; + break; + case "retrydelay": + if (_metrics.RetryDelayDuration == null) + { + _metrics.RetryDelayDuration = duration; + } + else + { + _metrics.RetryDelayDuration += duration; + } + + break; + } + + _stageStopwatch = null; + } + + /// + /// Records successful SSL handshake completion and captures protocol details. + /// + public void RecordSuccess(SslStream sslStream) + { + _totalStopwatch.Stop(); + _metrics.IsSuccessful = true; + _metrics.EndTime = DateTime.UtcNow; + _metrics.FailureReason = null; + + if (sslStream != null) + { + try + { + _metrics.SslProtocolVersion = sslStream.SslProtocol.ToString(); + _metrics.CipherAlgorithm = sslStream.CipherAlgorithm.ToString(); + _metrics.HashAlgorithm = sslStream.HashAlgorithm.ToString(); + _metrics.KeyExchangeAlgorithm = sslStream.KeyExchangeAlgorithm.ToString(); + } + catch + { + // No cross-platform way to get these details, so ignore if not available. + } + } + + _metrics.CumulativeDuration = _totalStopwatch.Elapsed; + } + + /// + /// Records SSL handshake failure with error details. + /// + public void RecordFailure(Exception exception, bool isRecoverable) + { + _totalStopwatch.Stop(); + _metrics.IsSuccessful = false; + _metrics.EndTime = DateTime.UtcNow; + _metrics.FailureReason = $"{exception?.GetType().Name ?? "Unknown"}: {exception?.Message ?? "No details"}"; + _metrics.IsRecoverableFailure = isRecoverable; + _metrics.CumulativeDuration = _totalStopwatch.Elapsed; + } + + /// + /// Analyzes the failure and determines if it's recoverable. + /// Non-recoverable failures: authentication failures, certificate errors, protocol mismatches + /// Recoverable failures: timeouts, network interruptions, EOF during handshake + /// + public static bool IsRecoverableFailure(Exception exception) + { + if (exception == null) + { + return false; + } + + var message = exception.Message?.ToLower() ?? ""; + var typeName = exception.GetType().Name.ToLower(); + + // Non-recoverable SSL authentication and certificate errors + if (typeName.Contains("authenticationexception")) + { + return false; + } + + if (message.Contains("certificate rejected")) + { + return false; + } + + if (message.Contains("certificate validation failed")) + { + return false; + } + + if (message.Contains("certificate not trusted")) + { + return false; + } + + if (message.Contains("certificate chain")) + { + return false; + } + + if (message.Contains("sslhandshakefailure")) + { + return false; + } + + if (message.Contains("the request was aborted")) + { + return false; + } + + // Check for handshake_failure TLS alerts + if (message.Contains("handshake_failure")) + { + return false; + } + + if (message.Contains("protocol_version")) + { + return false; + } + + if (message.Contains("unsupported_certificate_type")) + { + return false; + } + + // Recoverable I/O and network errors + if (message.Contains("0 bytes from the transport stream")) + { + return true; + } + + if (message.Contains("unexpected eof")) + { + return true; + } + + if (message.Contains("connection reset")) + { + return true; + } + + if (message.Contains("connection aborted")) + { + return true; + } + + if (message.Contains("timed out")) + { + return true; + } + + if (message.Contains("timeout")) + { + return true; + } + + if (message.Contains("network unreachable")) + { + return true; + } + + if (message.Contains("connection refused")) + { + return true; + } + + if (typeName.Contains("ioexception")) + { + return true; + } + + if (typeName.Contains("socketexception")) + { + return true; + } + + if (typeName.Contains("operationcanceledexception")) + { + return true; + } + + if (exception.InnerException != null) + { + return IsRecoverableFailure(exception.InnerException); + } + + // Default: assume recoverable for network-layer exceptions + return typeName.Contains("exception"); + } + + /// + /// Returns a diagnostic summary of the handshake attempt. + /// + public override string ToString() => _metrics.ToString(); + } +} diff --git a/EonaCat.Connections/Helpers/SslMetrics.cs b/EonaCat.Connections/Helpers/SslMetrics.cs new file mode 100644 index 0000000..b68a214 --- /dev/null +++ b/EonaCat.Connections/Helpers/SslMetrics.cs @@ -0,0 +1,126 @@ +using System; + +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. + + /// + /// Tracks metrics and timing information for SSL/TLS handshake stages. + /// + public class SslMetrics + { + /// + /// Gets the timestamp when the SSL handshake attempt started. + /// + public DateTime StartTime { get; set; } + + /// + /// Gets the timestamp when the SSL handshake completed (success or final failure). + /// + public DateTime? EndTime { get; set; } + + /// + /// Gets the total duration of the SSL handshake attempt. + /// + public TimeSpan? TotalDuration => EndTime.HasValue ? EndTime.Value - StartTime : null; + + /// + /// Gets the duration of the AuthenticateAsServer call (server-side). + /// + public TimeSpan? ServerAuthenticationDuration { get; set; } + + /// + /// Gets the duration of the AuthenticateAsClient call (client-side). + /// + public TimeSpan? ClientAuthenticationDuration { get; set; } + + /// + /// Gets the time spent retrying SSL handshakes after failures. + /// + public TimeSpan? RetryDelayDuration { get; set; } + + /// + /// Gets the total time spent in all SSL handshake attempts including retries. + /// + public TimeSpan? CumulativeDuration { get; set; } + + /// + /// Gets the number of SSL handshake attempts made. + /// + public int AttemptCount { get; set; } + + /// + /// Gets the SSL protocol version negotiated (e.g., "Tls13", "Tls12"). + /// + public string? SslProtocolVersion { get; set; } + + /// + /// Gets the cipher algorithm used (e.g., "Aes256", "ChaCha20"). + /// + public string? CipherAlgorithm { get; set; } + + /// + /// Gets the hash algorithm used (e.g., "Sha256"). + /// + public string? HashAlgorithm { get; set; } + + /// + /// Gets the key exchange algorithm used (e.g., "Ecdh"). + /// + public string? KeyExchangeAlgorithm { get; set; } + + /// + /// Gets whether the SSL handshake was successful. + /// + public bool IsSuccessful { get; set; } + + /// + /// Gets the exception message if the handshake failed. + /// + public string? FailureReason { get; set; } + + /// + /// Gets whether the failure is recoverable (can retry) or not. + /// + public bool IsRecoverableFailure { get; set; } + + /// + /// Gets the remote endpoint that was connected to/from. + /// + public string? RemoteEndPoint { get; set; } + + /// + /// Gets whether mutual authentication (client certificate) was required. + /// + public bool MutualAuthenticationRequired { get; set; } + + /// + /// Returns a formatted string summary of the SSL handshake metrics. + /// + public override string ToString() + { + var result = $"SSL Metrics: Attempt {AttemptCount}, "; + result += IsSuccessful + ? $"Success in {TotalDuration?.TotalMilliseconds:F2}ms, Protocol={SslProtocolVersion}, Cipher={CipherAlgorithm}" + : $"Failed - {FailureReason} (Recoverable: {IsRecoverableFailure})"; + + if (ServerAuthenticationDuration.HasValue) + { + result += $", ServerAuth={ServerAuthenticationDuration.Value.TotalMilliseconds:F2}ms"; + } + + if (ClientAuthenticationDuration.HasValue) + { + result += $", ClientAuth={ClientAuthenticationDuration.Value.TotalMilliseconds:F2}ms"; + } + + if (CumulativeDuration.HasValue && AttemptCount > 1) + { + result += $", TotalRetries={CumulativeDuration.Value.TotalMilliseconds:F2}ms"; + } + + return result; + } + } +} diff --git a/EonaCat.Connections/Helpers/StringHelper.cs b/EonaCat.Connections/Helpers/StringHelper.cs index 6148d2a..cf9b476 100644 --- a/EonaCat.Connections/Helpers/StringHelper.cs +++ b/EonaCat.Connections/Helpers/StringHelper.cs @@ -1,23 +1,23 @@ -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. - - 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; - } - } - } -} +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. + + 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/TcpSeparators.cs b/EonaCat.Connections/Helpers/TcpSeparators.cs index 9d324b0..493391f 100644 --- a/EonaCat.Connections/Helpers/TcpSeparators.cs +++ b/EonaCat.Connections/Helpers/TcpSeparators.cs @@ -1,39 +1,39 @@ -using System.Text; - -// 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. - -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("]"); - } +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 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/Models/Configuration.cs b/EonaCat.Connections/Models/Configuration.cs index a595b1c..59ac115 100644 --- a/EonaCat.Connections/Models/Configuration.cs +++ b/EonaCat.Connections/Models/Configuration.cs @@ -1,13 +1,14 @@ using System.Diagnostics; +using System.Net; using System.Net.Security; using System.Security.Cryptography.X509Certificates; -// 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. - namespace EonaCat.Connections.Models { - public class Configuration + // 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 : IDisposable { public event EventHandler OnLog; public List TrustedThumbprints = new List(); @@ -15,8 +16,27 @@ namespace EonaCat.Connections.Models 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 int SSLTimeoutInSeconds { get; set; } = 35; + public int SSLRetryDelayInSeconds { get; set; } = 5; + + /// + /// Enables exponential backoff for SSL handshake retries. + /// When enabled, retry delays increase progressively: initial delay * (2 ^ attempt). + /// Caps out at SSLRetryDelayMaxSeconds to prevent excessive delays. + /// Default is false to provide instant retries after a fixed 5-second delay, allowing SSL handshakes up to 30 seconds to complete. + /// + public bool UseExponentialBackoffForSslRetries { get; set; } = false; + + /// + /// Maximum delay in seconds for SSL handshake retries when exponential backoff is enabled. (default: 60) + /// + public int SSLRetryDelayMaxSeconds { get; set; } = 60; + + /// + /// Enables diagnostic/tracing for SSL handshake process. + /// When enabled, detailed metrics and performance data are collected for each SSL connection attempt. + /// + public bool EnableSslDiagnostics { get; set; } = false; public FramingMode MessageFraming { get; set; } = FramingMode.None; public byte[] Delimiter { get; internal set; } = Helpers.TcpSeparators.Percent; @@ -24,17 +44,28 @@ namespace EonaCat.Connections.Models public const string PING_VALUE = "¯"; public const string PONG_VALUE = "‰"; + public const string SSL_ERROR_PREFIX = "[SSL_ERROR]"; + public const string SSL_ERROR_SUFFIX = "[/SSL_ERROR]"; public ProtocolType Protocol { get; set; } = ProtocolType.TCP; public int Port { get; set; } = 8080; public string Host { get; set; } = "127.0.0.1"; - public bool UseSsl { get; set; } = false; + public bool UseSsl => Certificate != null; public X509Certificate2 Certificate { get; set; } + public X509Certificate2Collection AdditionalCertificates { get; set; } public bool UseAesEncryption { get; set; } = false; - public int BufferSize { get; set; } = (int)BufferSizeMaximum.Medium; + public int BufferSize { get; set; } = (int)BufferSizeMaximum.ExtraLarge; public int MaxConnections { get; set; } = 100000; public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30); public bool EnableKeepAlive { get; set; } = true; + public int KeepAliveTimeSeconds { get; set; } = 60; + public int KeepAliveIntervalSeconds { get; set; } = 10; + + /// + /// Number of unacknowledged TCP keep-alive probes before the connection is considered dead. (default: 10) + /// + public int KeepAliveRetryCount { get; set; } = 10; + public bool EnableNagle { get; set; } = false; // For testing purposes, allow self-signed certificates @@ -44,13 +75,89 @@ namespace EonaCat.Connections.Models 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 AllowTlsRenegotiation { get; set; } + public bool UseBigEndian { get; set; } - internal int HeartbeatIntervalSeconds { get; set; } = 5; + public 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; } + public double IdleTimeoutSeconds { get; set; } = 30; // 0 means no idle timeout + + /// + /// Determines whether to enable RST (Reset) flag on socket close. + /// + public bool EnableRST { get; set; } + + /// + /// Gets or sets the total bytes to read as a message prefix (if framing mode is LengthPrefixed) is enabled. + /// + /// The length prefix index determines where the length information is read or written in + /// the buffer. Changing this value may affect how data is parsed or serialized, depending on the protocol or + /// format in use. (default: 4) + public int LengthPrefixedLength { get; set; } = 4; + + public bool EnableConnectionDebugLogs { get; set; } + + /// + /// Enables automatic periodic generation of HTML status reports for errors and network health. + /// When enabled, reports are written to every seconds. + /// + public bool EnableAutoHtmlReports { get; set; } + + /// + /// The directory where auto-generated HTML reports are written. Defaults to "reports" under the current directory. + /// + public string HtmlReportOutputDirectory { get; set; } = Path.Combine(Directory.GetCurrentDirectory(), "reports"); + + /// + /// The interval in seconds between automatic HTML report generations. (default: 60) + /// + public int HtmlReportIntervalSeconds { get; set; } = 60; + + /// + /// Enables automatic periodic generation of an HTML status page showing connected clients and throughput. + /// When enabled, the page is written to every seconds. + /// Default is off. + /// + public bool EnableServerStatusPage { get; set; } + + /// + /// The interval in seconds between automatic server status page generations. (default: 5) + /// + public int ServerStatusPageIntervalSeconds { get; set; } = 5; + + /// + /// Enables a lightweight REST API that exposes health and status information via HTTP. + /// When enabled, the API listens on (use 0 for a random available port). + /// Default is off. + /// + public bool EnableHealthApi { get; set; } + + /// + /// The port for the health REST API. Use 0 (default) for a random available port. + /// The actual port can be retrieved from NetworkServer.HealthApi.Port or NetworkClient.HealthApi.Port after starting. + /// + public int HealthApiPort { get; set; } = 0; + + /// + /// The IP address the health API server binds to. + /// Use IPAddress.Any (0.0.0.0) for Docker/container environments where the API must be reachable from outside the container. + /// Defaults to IPAddress.Loopback (127.0.0.1) for security. + /// + public IPAddress HealthApiBindAddress { get; set; } = IPAddress.Loopback; + + /// + /// Write timeout in seconds for network operations. If a write operation takes longer than this duration, it will be aborted and an exception will be thrown. (default: 120 seconds) + /// + public double WriteTimeoutSeconds { get; set; } = 120; + + /// + /// Read timeout in seconds for network operations. If a read operation takes longer than this duration, it will be aborted and an exception will be thrown. (default: 300 seconds) + /// + public double ReadTimeoutSeconds { get; set; } = 300; internal RemoteCertificateValidationCallback GetRemoteCertificateValidationCallback() { @@ -67,85 +174,161 @@ namespace EonaCat.Connections.Models { var stopwatch = Stopwatch.StartNew(); + bool result = false; + string reason = "Unknown"; + try { - if (IsSelfSignedEnabled) + if (certificate == null) { - OnLog?.Invoke(this, $"WARNING: Accepting all invalid certificates: {certificate?.Subject}"); - return true; + reason = "Certificate is null"; + return false; } - if (chain != null) + if (IsSelfSignedEnabled) { - chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; - chain.ChainPolicy.VerificationFlags = - X509VerificationFlags.IgnoreCertificateAuthorityRevocationUnknown | - X509VerificationFlags.IgnoreEndRevocationUnknown | - X509VerificationFlags.AllowUnknownCertificateAuthority; + reason = $"Accepting all certificates (IsSelfSignedEnabled): {certificate.Subject}"; + result = true; + return result; + } - chain.Build((X509Certificate2)certificate); - - foreach (var status in chain.ChainStatus) + try + { + if (chain != null && certificate is X509Certificate2 cert) { - OnLog?.Invoke(this, $"ChainStatus: {status.Status} - {status.StatusInformation}"); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = + X509VerificationFlags.IgnoreCertificateAuthorityRevocationUnknown | + X509VerificationFlags.IgnoreEndRevocationUnknown | + X509VerificationFlags.AllowUnknownCertificateAuthority; + + chain.Build(cert); + + foreach (var status in chain.ChainStatus) + { + OnLog?.Invoke(this, $"ChainStatus: {status.Status} - {status.StatusInformation}"); + } } } + catch (Exception ex) + { + OnLog?.Invoke(this, $"Certificate validation succeeded in {stopwatch.ElapsedMilliseconds} ms"); + } if (sslPolicyErrors == SslPolicyErrors.None) { - OnLog?.Invoke(this, $"Certificate validation succeeded in {stopwatch.ElapsedMilliseconds} ms"); - return true; + reason = "Certificate validation succeeded"; + result = true; + return result; } if (CheckAgainstInternalTrustedCertificates && certificate is X509Certificate2 cert2) { string thumbprint = cert2.Thumbprint?.Replace(" ", "").ToLowerInvariant(); - if (thumbprint != null && TrustedThumbprints.Contains(thumbprint)) + + if (!string.IsNullOrEmpty(thumbprint) && TrustedThumbprints.Contains(thumbprint)) { - OnLog?.Invoke(this, $"Trusted thumbprint matched: {thumbprint}"); - return true; + reason = $"Trusted thumbprint matched: {thumbprint}"; + result = true; + return result; } - OnLog?.Invoke(this, $"Certificate thumbprint {thumbprint} not trusted (Validation took {stopwatch.ElapsedMilliseconds} ms)"); - return false; + + reason = $"Certificate thumbprint not trusted: {thumbprint}"; + result = false; + return result; } if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors) && chain != null) { bool fatal = false; + foreach (var status in chain.ChainStatus) { - if (status.Status == X509ChainStatusFlags.Revoked) + switch (status.Status) { - OnLog?.Invoke(this, $"Certificate revoked: {status.StatusInformation}"); - fatal = true; - break; + case X509ChainStatusFlags.Revoked: + case X509ChainStatusFlags.NotSignatureValid: + fatal = true; + reason = $"Fatal chain error: {status.Status} - {status.StatusInformation}"; + break; } - if (status.Status == X509ChainStatusFlags.NotSignatureValid) + if (fatal) { - OnLog?.Invoke(this, $"Invalid signature: {status.StatusInformation}"); - fatal = true; break; } } if (!fatal) { - OnLog?.Invoke(this, $"Certificate accepted (ignoring minor chain warnings)"); - return true; + reason = "Certificate accepted (ignoring non-fatal chain warnings)"; + result = true; + return result; } - OnLog?.Invoke(this, $"Certificate validation failed (Validation took {stopwatch.ElapsedMilliseconds} ms)"); - return false; + result = false; + return result; } - OnLog?.Invoke(this, $"Certificate rejected: {sslPolicyErrors} (Validation took {stopwatch.ElapsedMilliseconds} ms)"); - return false; + reason = $"Certificate rejected: {sslPolicyErrors}"; + result = false; + return result; + } + catch (Exception ex) + { + reason = $"Certificate validation exception: {ex}"; + result = false; + return result; } finally { stopwatch.Stop(); + + OnLog?.Invoke(this, + $"Certificate validation result={result}. " + + $"Reason={reason}. " + + $"Duration={stopwatch.ElapsedMilliseconds} ms"); } } + + /// + /// Detects whether the application is running inside a Docker container. + /// + public static bool IsRunningInContainer => + Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true" || + File.Exists("/.dockerenv"); + + /// + /// Configures the settings for use in a Docker/container environment. + /// Sets the host to 0.0.0.0 and the health API bind address to IPAddress.Any. + /// + public Configuration UseContainerDefaults() + { + Host = "0.0.0.0"; + HealthApiBindAddress = IPAddress.Any; + return this; + } + + /// + /// Calculates the SSL retry delay for a given attempt number, accounting for exponential backoff if enabled. + /// + /// The attempt number (1-based). + /// The delay in seconds. + public int GetSslRetryDelaySeconds(int attemptNumber) + { + if (!UseExponentialBackoffForSslRetries || attemptNumber <= 1) + { + return SSLRetryDelayInSeconds; + } + + // Exponential backoff: baseDelay * 2^(attempt-1), capped at max + var exponentialDelay = SSLRetryDelayInSeconds * Math.Pow(2, attemptNumber - 1); + return (int)Math.Min(exponentialDelay, SSLRetryDelayMaxSeconds); + } + + public void Dispose() + { + Certificate?.Dispose(); + } } } \ No newline at end of file diff --git a/EonaCat.Connections/Models/Connection.cs b/EonaCat.Connections/Models/Connection.cs index b451997..2bf5317 100644 --- a/EonaCat.Connections/Models/Connection.cs +++ b/EonaCat.Connections/Models/Connection.cs @@ -1,223 +1,365 @@ -using System.Net; -using System.Net.Sockets; -using System.Security.Cryptography; - -// 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. - -namespace EonaCat.Connections.Models -{ - public class Connection - { - public string Id { get; set; } - public TcpClient TcpClient { get; set; } - public UdpClient UdpClient { get; set; } - public IPEndPoint RemoteEndPoint { get; set; } - public Stream Stream { get; set; } - - private string _nickName; - - public string Nickname - { - get - { - if (string.IsNullOrWhiteSpace(_nickName)) - { - _nickName = Id; - } - return _nickName; - } - - set - { - if (string.IsNullOrWhiteSpace(value)) - { - _nickName = Id; - } - else - { - _nickName = value; - } - } - } - - public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id; - - 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; } - public CancellationTokenSource CancellationToken { get; set; } - private long _bytesReceived; - private long _bytesSent; - public long BytesReceived => Interlocked.Read(ref _bytesReceived); - 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(); - } +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Channels; + +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 : IDisposable, IAsyncDisposable + { + public string Id { get; set; } + public TcpClient TcpClient { get; set; } + public UdpClient UdpClient { get; set; } + public IPEndPoint RemoteEndPoint { get; set; } + public Stream Stream { get; set; } + public SemaphoreSlim WriteLock { get; } = new(1, 1); + + internal Decoder Utf8Decoder { get; } = Encoding.UTF8.GetDecoder(); + internal char[] CharBuffer { get; set; } = new char[8192]; + + private string _nickName; + + public string Nickname + { + get => string.IsNullOrWhiteSpace(_nickName) ? Id : _nickName; + set => _nickName = string.IsNullOrWhiteSpace(value) ? Id : value; + } + + public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id; + + public bool IsConnected + { + get + { + try + { + if (TcpClient?.Client == null) + { + return false; + } + + var socket = TcpClient.Client; + if (!socket.Connected) + { + return false; + } + + // Send a zero-byte packet to check if the connection is still alive + try + { + socket.Send(Array.Empty(), 0, 0); + LastActive = DateTime.UtcNow; + } + catch + { + return false; + } + + return true; + } + catch + { + return false; + } + } + } + + public DateTime ConnectedAt { get; internal set; } + + private long _lastActiveTicks; + + public DateTime LastActive + { + get => new DateTime(Interlocked.Read(ref _lastActiveTicks)); + internal set => Interlocked.Exchange(ref _lastActiveTicks, value.Ticks); + } + + public DateTime DisconnectionTime { get; internal set; } + public DateTime LastDataSent { get; internal set; } + public DateTime LastDataReceived { get; internal set; } + + public Channel SendQueue = + Channel.CreateBounded(new BoundedChannelOptions(8192) + { + SingleReader = true, + SingleWriter = false, + //FullMode = BoundedChannelFullMode.DropOldest + }); + + public TimeSpan IdleTime() => DateTime.UtcNow - LastActive; + + public int IdleTimeInSeconds() => (int)IdleTime().TotalSeconds; + + public int IdleTimeInMinutes() => (int)IdleTime().TotalMinutes; + + public int IdleTimeInHours() => (int)IdleTime().TotalHours; + + public int IdleTimeInDays() => (int)IdleTime().TotalDays; + + public TimeSpan ConnectedTime() => DateTime.UtcNow - ConnectedAt; + + public int ConnectedTimeInSeconds() => (int)ConnectedTime().TotalSeconds; + + public int ConnectedTimeInMinutes() => (int)ConnectedTime().TotalMinutes; + + public int ConnectedTimeInHours() => (int)ConnectedTime().TotalHours; + + public int ConnectedTimeInDays() => (int)ConnectedTime().TotalDays; + + public string FormatTime(TimeSpan span, + bool includeDays = true, + bool includeHours = true, + bool includeMinutes = true, + bool includeSeconds = true, + bool includeMilliseconds = true) + { + var parts = new List(); + + if (includeDays) + { + parts.Add($"{(int)span.TotalDays:D2}d"); + } + + if (includeHours) + { + parts.Add($"{span.Hours:D2}h"); + } + + if (includeMinutes) + { + parts.Add($"{span.Minutes:D2}m"); + } + + if (includeSeconds) + { + parts.Add($"{span.Seconds:D2}s"); + } + + if (includeMilliseconds) + { + parts.Add($"{span.Milliseconds:D3}ms"); + } + + return string.Join(" ", parts); + } + + public string IdleTimeFormatted(bool days = true, bool hours = true, bool minutes = true, bool seconds = true, bool ms = true) + => FormatTime(IdleTime(), days, hours, minutes, seconds, ms); + + public string ConnectedTimeFormatted(bool days = true, bool hours = true, bool minutes = true, bool seconds = true, bool ms = true) + => FormatTime(ConnectedTime(), days, hours, minutes, seconds, ms); + + public bool IsSecure { get; set; } + public bool IsEncrypted { get; set; } + public Aes AesEncryption { get; set; } + public CancellationTokenSource CancellationToken { get; set; } + + private long _bytesReceived; + private long _bytesSent; + + public long BytesReceived => Interlocked.Read(ref _bytesReceived); + 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); + + private int _disconnected; + + public bool MarkDisconnected() => Interlocked.Exchange(ref _disconnected, 1) == 0; + + public Dictionary Metadata { get; } = new(); + + public Task? SendLoopTask; + public Task ReceiveDataTask { get; internal set; } + + public bool IsIdleTimeoutTriggered { get; internal set; } + public DateTime LastIdleLogUtc { get; internal set; } + + public double ShowIdleReminderInSeconds { get; set; } = 20; + public bool IsClosing { get; internal set; } + + private bool _disposed; + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + IsClosing = true; + DisconnectionTime = DateTime.UtcNow; + + try + { + CancellationToken?.Cancel(); + } + catch + { + // Do nothing + } + + SendQueue.Writer.TryComplete(); + + if (SendLoopTask != null) + { + await SafeAwait(SendLoopTask); + } + + if (ReceiveDataTask != null) + { + await SafeAwait(ReceiveDataTask); + } + + DisposeManaged(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + IsClosing = true; + DisconnectionTime = DateTime.UtcNow; + + try + { + CancellationToken?.Cancel(); + } + catch + { + // Do nothing + } + + SendQueue.Writer.TryComplete(); + + DisposeManaged(); + GC.SuppressFinalize(this); + } + + private void DisposeManaged() + { + try + { + Stream?.Dispose(); + } + catch + { + // Do nothing + } + + try + { + ForceCloseTcpClient(TcpClient); + } + catch + { + // Do nothing + } + + try + { + UdpClient?.Close(); + UdpClient?.Dispose(); + } + catch + { + // Do nothing + } + + try + { + AesEncryption?.Dispose(); + } + catch + { + // Do nothing + } + + try + { + CancellationToken?.Dispose(); + } + catch + { + // Do nothing + } + + try + { + WriteLock?.Dispose(); + } + catch + { + // Do nothing + } + + Metadata.Clear(); + + Stream = null; + TcpClient = null; + UdpClient = null; + AesEncryption = null; + CancellationToken = null; + SendLoopTask = null; + ReceiveDataTask = null; + CharBuffer = null; + } + + private static void ForceCloseTcpClient(TcpClient tcpClient) + { + if (tcpClient == null) + { + return; + } + + try + { + tcpClient.Client?.Shutdown(SocketShutdown.Both); + } + catch + { + // Do nothing + } + + try + { + tcpClient.Close(); + } + catch + { + // Do nothing + } + + try + { + tcpClient.Dispose(); + } + catch + { + // Do nothing + } + } + + private static async Task SafeAwait(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + // Do nothing + } + } + } } \ No newline at end of file diff --git a/EonaCat.Connections/Models/FramingMode.cs b/EonaCat.Connections/Models/FramingMode.cs index e99c942..7bb4911 100644 --- a/EonaCat.Connections/Models/FramingMode.cs +++ b/EonaCat.Connections/Models/FramingMode.cs @@ -1,12 +1,12 @@ -// 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. - -namespace EonaCat.Connections.Models -{ - public enum FramingMode - { - None, - Delimiter, - LengthPrefixed - } +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 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 index 0dc2638..dc26426 100644 --- a/EonaCat.Connections/Models/ProcessedMessage.cs +++ b/EonaCat.Connections/Models/ProcessedMessage.cs @@ -1,16 +1,22 @@ -// 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. - -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; - } +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 ProcessedMessage : IDisposable + { + public TData Data { get; set; } + public string ClientName { get; set; } + public string? ClientEndpoint { get; set; } + public bool HasClientEndpoint => !string.IsNullOrEmpty(ClientEndpoint); + public bool HasObject => Data != null; + + public void Dispose() + { + if (Data is IDisposable disposable) + { + disposable.Dispose(); + } + } + } } \ No newline at end of file diff --git a/EonaCat.Connections/Models/ProcessedTextMessage.cs b/EonaCat.Connections/Models/ProcessedTextMessage.cs index af6252e..59111d1 100644 --- a/EonaCat.Connections/Models/ProcessedTextMessage.cs +++ b/EonaCat.Connections/Models/ProcessedTextMessage.cs @@ -1,12 +1,17 @@ -// 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. - -namespace EonaCat.Connections.Models -{ - public class ProcessedTextMessage - { - public string Text { get; set; } - public string ClientName { get; set; } - public string? ClientEndpoint { get; set; } - } +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 ProcessedTextMessage : IDisposable + { + public string Text { get; set; } + public string ClientName { get; set; } + public string? ClientEndpoint { get; set; } + + public void Dispose() + { + // Nothing to dispose + } + } } \ No newline at end of file diff --git a/EonaCat.Connections/Models/ProtocolType.cs b/EonaCat.Connections/Models/ProtocolType.cs index 0c2464b..bbf5aca 100644 --- a/EonaCat.Connections/Models/ProtocolType.cs +++ b/EonaCat.Connections/Models/ProtocolType.cs @@ -1,8 +1,8 @@ -// 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. - -namespace EonaCat.Connections.Models -{ +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 enum ProtocolType { TCP, diff --git a/EonaCat.Connections/Models/ServerStatusPage.cs b/EonaCat.Connections/Models/ServerStatusPage.cs new file mode 100644 index 0000000..1f03c86 --- /dev/null +++ b/EonaCat.Connections/Models/ServerStatusPage.cs @@ -0,0 +1,359 @@ +using System.Collections.Concurrent; +using System.Text; + +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 ServerStatusPage : IDisposable + { + private CancellationTokenSource _autoReportCts; + private Task _autoReportTask; + private volatile bool _autoReportRunning; + private readonly Func> _getClients; + private readonly Func _getStats; + private readonly Configuration _config; + private readonly SocketStatusPage _errorPage; + + public ServerStatusPage( + Func> getClients, + Func getStats, + Configuration config, + SocketStatusPage errorPage = null) + { + _getClients = getClients; + _getStats = getStats; + _config = config; + _errorPage = errorPage; + } + + /// + /// Gets whether automatic HTML status page generation is currently running. + /// + public bool IsAutoReportRunning => _autoReportRunning; + + /// + /// Starts periodic automatic generation of the HTML server status page. + /// + /// Directory where the HTML file is written. + /// Interval in seconds between page generations. + /// The HTML file name. + public void StartAutoReport(string outputDirectory, int intervalSeconds = 5, string fileName = "status-server.html") + { + if (_autoReportRunning) + { + return; + } + + _autoReportCts?.Dispose(); + _autoReportCts = new CancellationTokenSource(); + _autoReportRunning = true; + + var interval = TimeSpan.FromSeconds(Math.Max(1, intervalSeconds)); + var token = _autoReportCts.Token; + + _autoReportTask = Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + if (!Directory.Exists(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + var filePath = Path.Combine(outputDirectory, fileName); + var html = GenerateHtml(); + File.WriteAllText(filePath, html, Encoding.UTF8); + } + catch + { + // Swallow to keep the loop alive + } + + try + { + await Task.Delay(interval, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + }, token); + } + + /// + /// Stops the automatic HTML status page generation. + /// + public void StopAutoReport() + { + if (!_autoReportRunning) + { + return; + } + + _autoReportRunning = false; + _autoReportCts?.Cancel(); + + try + { + _autoReportTask?.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + } + } + + public string GenerateHtml(string title = "Server Status") + { + var stats = _getStats(); + var clients = _getClients(); + var now = DateTime.UtcNow; + + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($""); + sb.AppendLine($"{HtmlEncode(title)}"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine($"

{HtmlEncode(title)}

"); + + // Server summary cards + sb.AppendLine("
"); + sb.AppendLine($"
Listen Address
{HtmlEncode(_config.Host)}:{_config.Port}
"); + sb.AppendLine($"
Protocol
{HtmlEncode(_config.Protocol.ToString())}
"); + sb.AppendLine($"
Connected Clients
{stats.ActiveConnections}
"); + sb.AppendLine($"
Max Connections
{_config.MaxConnections}
"); + sb.AppendLine($"
Total Connections
{stats.TotalConnections}
"); + sb.AppendLine($"
Dropped Connections
{stats.DroppedConnections}
"); + sb.AppendLine($"
Dropped Packets
{stats.DroppedPackets}
"); + sb.AppendLine($"
Uptime
{FormatTimeSpan(stats.Uptime)}
"); + sb.AppendLine("
"); + + // Throughput cards + sb.AppendLine("
"); + sb.AppendLine($"
Total Bytes Sent
{FormatBytes(stats.BytesSent)}
"); + sb.AppendLine($"
Total Bytes Received
{FormatBytes(stats.BytesReceived)}
"); + sb.AppendLine($"
Messages Sent
{stats.MessagesSent}
"); + sb.AppendLine($"
Messages Received
{stats.MessagesReceived}
"); + sb.AppendLine($"
Msg/sec
{stats.MessagesPerSecond:F2}
"); + sb.AppendLine("
"); + + // Connected clients table + sb.AppendLine("

Connected Clients

"); + + var clientList = clients.Values.ToArray(); + + if (clientList.Length == 0) + { + sb.AppendLine("
"); + sb.AppendLine("
🔒
"); + sb.AppendLine("

No clients connected.

"); + sb.AppendLine("
"); + } + else + { + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + foreach (var client in clientList) + { + var securityBadge = client.IsEncrypted + ? "AES Encrypted" + : client.IsSecure + ? "SSL/TLS" + : "Plain"; + + sb.AppendLine(""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine(""); + } + + sb.AppendLine(""); + sb.AppendLine("
NicknameClient IDRemote EndpointConnected SinceConnected TimeIdle TimeBytes SentBytes ReceivedLast Data SentLast Data ReceivedSecurity
{HtmlEncode(client.Nickname)}{HtmlEncode(client.Id ?? "-")}{HtmlEncode(client.RemoteEndPoint?.ToString() ?? "-")}{client.ConnectedAt:yyyy-MM-dd HH:mm:ss} UTC{FormatTimeSpan(client.ConnectedTime())}{FormatTimeSpan(client.IdleTime())}{FormatBytes(client.BytesSent)}{FormatBytes(client.BytesReceived)}{FormatDateTime(client.LastDataSent)}{FormatDateTime(client.LastDataReceived)}{securityBadge}
"); + } + + // SSL Errors section + if (_errorPage != null) + { + var sslErrors = _errorPage.GetSslErrors(); + sb.AppendLine("

SSL Errors

"); + + if (sslErrors.Count == 0) + { + sb.AppendLine("
"); + sb.AppendLine("

✔ No SSL errors recorded.

"); + sb.AppendLine("
"); + } + else + { + sb.AppendLine("
"); + sb.AppendLine($"
Total SSL Errors
{sslErrors.Count}
"); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + foreach (var sslError in sslErrors) + { + sb.AppendLine(""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine(""); + } + + sb.AppendLine(""); + sb.AppendLine("
TimestampClient / ServerClient IDErrorException Type
{sslError.Timestamp:yyyy-MM-dd HH:mm:ss} UTC{HtmlEncode(sslError.Nickname ?? sslError.ClientId ?? "-")}{HtmlEncode(sslError.ClientId ?? "-")}{HtmlEncode(sslError.Message ?? "-")}{HtmlEncode(sslError.ExceptionType ?? "-")}
"); + } + } + + sb.AppendLine($"
Generated at {now:yyyy-MM-dd HH:mm:ss} UTC — Auto-refresh every {_config.ServerStatusPageIntervalSeconds}s — EonaCat.Connections
"); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + + return sb.ToString(); + } + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) + { + return $"{bytes} B"; + } + + if (bytes < 1024 * 1024) + { + return $"{bytes / 1024.0:F1} KB"; + } + + if (bytes < 1024 * 1024 * 1024) + { + return $"{bytes / (1024.0 * 1024.0):F2} MB"; + } + + return $"{bytes / (1024.0 * 1024.0 * 1024.0):F2} GB"; + } + + private static string FormatTimeSpan(TimeSpan span) + { + if (span.TotalDays >= 1) + { + return $"{(int)span.TotalDays}d {span.Hours:D2}h {span.Minutes:D2}m {span.Seconds:D2}s"; + } + + if (span.TotalHours >= 1) + { + return $"{span.Hours:D2}h {span.Minutes:D2}m {span.Seconds:D2}s"; + } + + if (span.TotalMinutes >= 1) + { + return $"{span.Minutes:D2}m {span.Seconds:D2}s"; + } + + return $"{span.Seconds}s"; + } + + private static string FormatDateTime(DateTime dt) + { + if (dt == default) + { + return "-"; + } + + return $"{dt:yyyy-MM-dd HH:mm:ss} UTC"; + } + + private static string HtmlEncode(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); + } + + public void Dispose() + { + StopAutoReport(); + _autoReportCts?.Dispose(); + } + } +} diff --git a/EonaCat.Connections/Models/SocketErrorEntry.cs b/EonaCat.Connections/Models/SocketErrorEntry.cs new file mode 100644 index 0000000..e2c09c6 --- /dev/null +++ b/EonaCat.Connections/Models/SocketErrorEntry.cs @@ -0,0 +1,32 @@ +using System.Net.Sockets; + +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 SocketErrorEntry + { + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public string Source { get; set; } + public string ClientId { get; set; } + public string Nickname { get; set; } + public SocketError? SocketErrorCode { get; set; } + public string ErrorCode { get; set; } + public string Message { get; set; } + public string ExceptionType { get; set; } + public string StackTrace { get; set; } + public Exception Exception { get; set; } + public bool IsSslError { get; set; } + + public bool IsSocketException => SocketErrorCode.HasValue; + + public override string ToString() + { + var code = SocketErrorCode.HasValue ? $" [{SocketErrorCode}]" : string.Empty; + var client = !string.IsNullOrEmpty(ClientId) ? $" Client={ClientId}" : string.Empty; + var nick = !string.IsNullOrEmpty(Nickname) ? $" ({Nickname})" : string.Empty; + return $"[{Timestamp:O}] [{Source}]{code}{client}{nick} {Message}"; + } + } +} diff --git a/EonaCat.Connections/Models/SocketStatusPage.cs b/EonaCat.Connections/Models/SocketStatusPage.cs new file mode 100644 index 0000000..3e57236 --- /dev/null +++ b/EonaCat.Connections/Models/SocketStatusPage.cs @@ -0,0 +1,521 @@ +using System.Collections.Concurrent; +using System.Net.Sockets; +using System.Text; + +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 SocketStatusPage : IDisposable + { + private readonly ConcurrentQueue _errors = new ConcurrentQueue(); + private readonly int _maxEntries; + private CancellationTokenSource _autoReportCts; + private Task _autoReportTask; + private volatile bool _autoReportRunning; + + public SocketStatusPage(int maxEntries = 1000) + { + _maxEntries = maxEntries; + } + + /// + /// Starts periodic automatic generation of the HTML status page. + /// + /// Directory where the HTML file is written. + /// Interval in seconds between report generations. + /// The HTML file name. + public void StartAutoHtmlReport(string outputDirectory, int intervalSeconds = 60, string fileName = "status-errors.html") + { + if (_autoReportRunning) + { + return; + } + + _autoReportCts?.Dispose(); + _autoReportCts = new CancellationTokenSource(); + _autoReportRunning = true; + + var interval = TimeSpan.FromSeconds(Math.Max(5, intervalSeconds)); + var token = _autoReportCts.Token; + + _autoReportTask = Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + if (!Directory.Exists(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + var filePath = Path.Combine(outputDirectory, fileName); + SaveHtmlStatusPage(filePath); + } + catch + { + // Swallow to keep the loop alive + } + + try + { + await Task.Delay(interval, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + }, token); + } + + /// + /// Stops the automatic HTML report generation. + /// + public void StopAutoHtmlReport() + { + if (!_autoReportRunning) + { + return; + } + + _autoReportRunning = false; + _autoReportCts?.Cancel(); + + try + { + _autoReportTask?.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + } + } + + /// + /// Gets whether automatic HTML report generation is currently running. + /// + public bool IsAutoHtmlReportRunning => _autoReportRunning; + + public void AddError(SocketErrorEntry entry) + { + if (entry == null) + { + return; + } + + _errors.Enqueue(entry); + + while (_errors.Count > _maxEntries) + { + _errors.TryDequeue(out _); + } + } + + public IReadOnlyList GetAllErrors() + { + return _errors.ToArray(); + } + + public IReadOnlyList GetRecentErrors(int count) + { + var all = _errors.ToArray(); + int skip = Math.Max(0, all.Length - count); + var result = new SocketErrorEntry[Math.Min(count, all.Length)]; + Array.Copy(all, skip, result, 0, result.Length); + return result; + } + + public IReadOnlyList GetErrorsSince(DateTime sinceUtc) + { + var result = new List(); + foreach (var entry in _errors) + { + if (entry.Timestamp >= sinceUtc) + { + result.Add(entry); + } + } + return result; + } + + public IReadOnlyList GetErrorsBySource(string source) + { + var result = new List(); + foreach (var entry in _errors) + { + if (string.Equals(entry.Source, source, StringComparison.OrdinalIgnoreCase)) + { + result.Add(entry); + } + } + return result; + } + + public IReadOnlyList GetSocketExceptions() + { + var result = new List(); + foreach (var entry in _errors) + { + if (entry.IsSocketException) + { + result.Add(entry); + } + } + return result; + } + + public IReadOnlyList GetErrorsBySocketErrorCode(SocketError errorCode) + { + var result = new List(); + foreach (var entry in _errors) + { + if (entry.SocketErrorCode == errorCode) + { + result.Add(entry); + } + } + return result; + } + + public int TotalErrors => _errors.Count; + + public int SocketExceptionCount + { + get + { + int count = 0; + foreach (var entry in _errors) + { + if (entry.IsSocketException) + { + count++; + } + } + return count; + } + } + + public int GeneralExceptionCount + { + get + { + int count = 0; + foreach (var entry in _errors) + { + if (!entry.IsSocketException) + { + count++; + } + } + return count; + } + } + + public int SslErrorCount + { + get + { + int count = 0; + foreach (var entry in _errors) + { + if (entry.IsSslError) + { + count++; + } + } + return count; + } + } + + public IReadOnlyList GetSslErrors() + { + var result = new List(); + foreach (var entry in _errors) + { + if (entry.IsSslError) + { + result.Add(entry); + } + } + return result; + } + + public SocketErrorEntry LastError + { + get + { + var all = _errors.ToArray(); + return all.Length > 0 ? all[all.Length - 1] : null; + } + } + + public void Clear() + { + while (_errors.TryDequeue(out _)) { } + } + + public string GetSummary() + { + var errors = _errors.ToArray(); + if (errors.Length == 0) + { + return "No errors recorded."; + } + + var sb = new StringBuilder(); + sb.AppendLine($"=== Socket Status Page ==="); + sb.AppendLine($"Total Errors: {errors.Length}"); + sb.AppendLine($"Socket Exceptions: {errors.Count(e => e.IsSocketException)}"); + sb.AppendLine($"Other Exceptions: {errors.Count(e => !e.IsSocketException)}"); + sb.AppendLine($"First Error: {errors[0].Timestamp:O}"); + sb.AppendLine($"Last Error: {errors[errors.Length - 1].Timestamp:O}"); + + var bySource = new Dictionary(StringComparer.OrdinalIgnoreCase); + var byCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in errors) + { + var source = entry.Source ?? "Unknown"; + if (bySource.ContainsKey(source)) + { + bySource[source]++; + } + else + { + bySource[source] = 1; + } + + var code = entry.ErrorCode ?? "N/A"; + if (byCode.ContainsKey(code)) + { + byCode[code]++; + } + else + { + byCode[code] = 1; + } + } + + sb.AppendLine(); + sb.AppendLine("By Source:"); + foreach (var kvp in bySource) + { + sb.AppendLine($" {kvp.Key}: {kvp.Value}"); + } + + sb.AppendLine(); + sb.AppendLine("By Error Code:"); + foreach (var kvp in byCode.OrderByDescending(x => x.Value)) + { + sb.AppendLine($" {kvp.Key}: {kvp.Value}"); + } + + return sb.ToString(); + } + + public string GenerateHtmlStatusPage(string title = "Socket Status Page") + { + var errors = _errors.ToArray(); + var now = DateTime.UtcNow; + + var bySource = new Dictionary(StringComparer.OrdinalIgnoreCase); + var byCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in errors) + { + var source = entry.Source ?? "Unknown"; + if (bySource.ContainsKey(source)) + { + bySource[source]++; + } + else + { + bySource[source] = 1; + } + + var code = entry.ErrorCode ?? "N/A"; + if (byCode.ContainsKey(code)) + { + byCode[code]++; + } + else + { + byCode[code] = 1; + } + } + + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{HtmlEncode(title)}"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine($"

{HtmlEncode(title)}

"); + + var statusClass = errors.Length == 0 ? "ok" : errors.Length < 10 ? "warn" : "error"; + var socketCount = errors.Count(e => e.IsSocketException); + var generalCount = errors.Count(e => !e.IsSocketException && !e.IsSslError); + var sslCount = errors.Count(e => e.IsSslError); + + sb.AppendLine("
"); + sb.AppendLine($"
Total Errors
{errors.Length}
"); + sb.AppendLine($"
0 ? "error" : "ok")}\">
Socket Exceptions
{socketCount}
"); + sb.AppendLine($"
0 ? "error" : "ok")}\">
SSL Errors
{sslCount}
"); + sb.AppendLine($"
0 ? "warn" : "ok")}\">
General Exceptions
{generalCount}
"); + + if (errors.Length > 0) + { + sb.AppendLine($"
First Error
{errors[0].Timestamp:yyyy-MM-dd HH:mm:ss} UTC
"); + sb.AppendLine($"
Last Error
{errors[errors.Length - 1].Timestamp:yyyy-MM-dd HH:mm:ss} UTC
"); + } + + sb.AppendLine("
"); + + if (errors.Length == 0) + { + sb.AppendLine("
"); + sb.AppendLine("
"); + sb.AppendLine("

No errors recorded. All systems operational.

"); + sb.AppendLine("
"); + } + else + { + sb.AppendLine("
"); + + sb.AppendLine("
"); + sb.AppendLine("

By Source

"); + foreach (var kvp in bySource) + { + sb.AppendLine($"
{HtmlEncode(kvp.Key)}{kvp.Value}
"); + } + sb.AppendLine("
"); + + sb.AppendLine("
"); + sb.AppendLine("

By Error Code

"); + foreach (var kvp in byCode.OrderByDescending(x => x.Value)) + { + sb.AppendLine($"
{HtmlEncode(kvp.Key)}{kvp.Value}
"); + } + sb.AppendLine("
"); + + sb.AppendLine("
"); + + sb.AppendLine("

Error Log

"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + for (int i = errors.Length - 1; i >= 0; i--) + { + var e = errors[i]; + var rowClass = e.IsSslError ? "ssl-error" : e.IsSocketException ? "socket-error" : "general-error"; + var badge = e.IsSslError + ? "SSL" + : e.IsSocketException + ? "Socket" + : "General"; + + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + + sb.AppendLine(""); + + sb.AppendLine(""); + } + + sb.AppendLine(""); + sb.AppendLine("
TimestampSourceTypeClientError CodeMessageDetails
{e.Timestamp:yyyy-MM-dd HH:mm:ss}{HtmlEncode(e.Source ?? "Unknown")}{badge}{HtmlEncode(e.Nickname ?? e.ClientId ?? "-")}{HtmlEncode(e.ErrorCode ?? "-")}{HtmlEncode(e.Message ?? "-")}"); + if (!string.IsNullOrEmpty(e.StackTrace)) + { + sb.AppendLine($"
Stack Trace
{HtmlEncode(e.StackTrace)}
"); + } + else + { + sb.AppendLine("-"); + } + sb.AppendLine("
"); + } + + sb.AppendLine($"
Generated at {now:yyyy-MM-dd HH:mm:ss} UTC — EonaCat.Connections
"); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + + return sb.ToString(); + } + + public void SaveHtmlStatusPage(string filePath, string title = "Socket Status Page") + { + var html = GenerateHtmlStatusPage(title); + File.WriteAllText(filePath, html, Encoding.UTF8); + } + + private static string HtmlEncode(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); + } + + public void Dispose() + { + StopAutoHtmlReport(); + _autoReportCts?.Dispose(); + } + } +} diff --git a/EonaCat.Connections/Models/Stats.cs b/EonaCat.Connections/Models/Stats.cs index 78e0e9f..1f79216 100644 --- a/EonaCat.Connections/Models/Stats.cs +++ b/EonaCat.Connections/Models/Stats.cs @@ -1,18 +1,84 @@ -// 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. - -namespace EonaCat.Connections -{ - public class Stats - { - public int ActiveConnections { get; set; } - public long TotalConnections { get; set; } - public long BytesSent { get; set; } - public long BytesReceived { get; set; } - public long MessagesSent { get; set; } - public long MessagesReceived { get; set; } - public DateTime StartTime { get; set; } - public TimeSpan Uptime => DateTime.UtcNow - StartTime; - public double MessagesPerSecond => MessagesReceived / Math.Max(1, Uptime.TotalSeconds); - } +using System.Threading; + +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 : IDisposable + { + private int _activeConnections; + private long _totalConnections; + private long _bytesSent; + private long _bytesReceived; + private long _messagesSent; + private long _messagesReceived; + private long _droppedPackets; + private long _droppedConnections; + + public int ActiveConnections + { + get => Volatile.Read(ref _activeConnections); + set => Volatile.Write(ref _activeConnections, value); + } + + public long TotalConnections + { + get => Interlocked.Read(ref _totalConnections); + set => Interlocked.Exchange(ref _totalConnections, value); + } + + public long BytesSent + { + get => Interlocked.Read(ref _bytesSent); + set => Interlocked.Exchange(ref _bytesSent, value); + } + + public long BytesReceived + { + get => Interlocked.Read(ref _bytesReceived); + set => Interlocked.Exchange(ref _bytesReceived, value); + } + + public long MessagesSent + { + get => Interlocked.Read(ref _messagesSent); + set => Interlocked.Exchange(ref _messagesSent, value); + } + + public long MessagesReceived + { + get => Interlocked.Read(ref _messagesReceived); + set => Interlocked.Exchange(ref _messagesReceived, value); + } + + public DateTime StartTime { get; set; } + public TimeSpan Uptime => DateTime.UtcNow - StartTime; + public double MessagesPerSecond => MessagesReceived / Math.Max(1, Uptime.TotalSeconds); + + public long DroppedPackets + { + get => Interlocked.Read(ref _droppedPackets); + set => Interlocked.Exchange(ref _droppedPackets, value); + } + + public long DroppedConnections + { + get => Interlocked.Read(ref _droppedConnections); + set => Interlocked.Exchange(ref _droppedConnections, value); + } + + public void IncrementTotalConnections() => Interlocked.Increment(ref _totalConnections); + public void IncrementDroppedConnections() => Interlocked.Increment(ref _droppedConnections); + public void IncrementDroppedPackets() => Interlocked.Increment(ref _droppedPackets); + public void IncrementMessagesSent() => Interlocked.Increment(ref _messagesSent); + public void IncrementMessagesReceived() => Interlocked.Increment(ref _messagesReceived); + public void AddBytesSent(long bytes) => Interlocked.Add(ref _bytesSent, bytes); + public void AddBytesReceived(long bytes) => Interlocked.Add(ref _bytesReceived, bytes); + + public void Dispose() + { + // Nothing to dispose + } + } } \ No newline at end of file diff --git a/EonaCat.Connections/NetworkClient.cs b/EonaCat.Connections/NetworkClient.cs index c222a61..f1e0378 100644 --- a/EonaCat.Connections/NetworkClient.cs +++ b/EonaCat.Connections/NetworkClient.cs @@ -1,7 +1,4 @@ -// 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. - -using EonaCat.Connections.EventArguments; +using EonaCat.Connections.EventArguments; using EonaCat.Connections.Helpers; using EonaCat.Connections.Models; using System.Buffers; @@ -17,35 +14,62 @@ 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 : IAsyncDisposable, IDisposable { + private const int DEFAULT_WAITING_TIMEOUT_IN_SECONDS = 30; private readonly Configuration _config; + private Task _autoReconnectTask; private TcpClient _tcpClient; private UdpClient _udpClient; private Stream _stream; private Aes _aesEncryption; - private CancellationTokenSource _cancellation; - private bool _isConnected; + private Task _receiveTask; + + private CancellationTokenSource _clientCts; + private CancellationTokenSource _connectionCts; + + private readonly Decoder _utf8Decoder = Encoding.UTF8.GetDecoder(); + private char[] _charBuffer = new char[8192]; + + private static readonly byte[] PingBytes = Encoding.UTF8.GetBytes(Configuration.PING_VALUE); + private static readonly byte[] PongBytes = Encoding.UTF8.GetBytes(Configuration.PONG_VALUE); + + public event EventHandler OnLog; + + public bool IsConnected + { + get; + set; + } private Task _pingTask; private Task _pongTask; + private Task _idleMonitorTask; - 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; public bool IsTcp => _config != null && _config.Protocol == ProtocolType.TCP; - private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly SemaphoreSlim _connectLock = new(1, 1); + private readonly SemaphoreSlim _streamReadLock = new(1, 1); + private readonly SemaphoreSlim _streamWriteLock = new(1, 1); + private readonly SemaphoreSlim _connectionAttemptLock = new(1, 1); - public event EventHandler OnLog; + private long _bytesSent; + private long _bytesReceived; + private long _messagesSent; + private long _messagesReceived; public DateTime ConnectionTime { get; private set; } public TimeSpan Uptime => DateTime.UtcNow - ConnectionTime; public DateTime LastActive { get; internal set; } + public long BytesSent => Interlocked.Read(ref _bytesSent); + public long BytesReceived => Interlocked.Read(ref _bytesReceived); + public long MessagesSent => Interlocked.Read(ref _messagesSent); + public long MessagesReceived => Interlocked.Read(ref _messagesReceived); public int IdleTimeInSeconds() { @@ -171,6 +195,8 @@ namespace EonaCat.Connections private bool _disposed; private bool _stopAutoReconnecting; + private DisconnectReason _reason; + private bool _wasConnected; public event EventHandler OnConnected; @@ -187,26 +213,121 @@ namespace EonaCat.Connections public event EventHandler OnGeneralError; public event EventHandler OnPingResponse; - public event EventHandler OnPongMissed; + public event EventHandler OnPongResponse; + + public event EventHandler OnSocketError; + + public SocketStatusPage StatusPage { get; } = new SocketStatusPage(); + + public HealthApiServer HealthApi { get; } public NetworkClient(Configuration config) { _config = config ?? throw new ArgumentNullException(nameof(config)); + _clientCts = new CancellationTokenSource(); + HealthApi = new HealthApiServer(BuildHealthJson, BuildStatusJson); + } + + private string BuildHealthJson() + { + var E = HealthApiServer.JsonEscape; + return "{" + + "\"status\":\"ok\"," + + "\"type\":\"client\"," + + $"\"serverAddress\":{E(_config.Host + ":" + _config.Port)}," + + $"\"protocol\":{E(_config.Protocol.ToString())}," + + $"\"isConnected\":{(IsConnected ? "true" : "false")}," + + $"\"isSecure\":{(IsSecure ? "true" : "false")}," + + $"\"isEncrypted\":{(IsEncrypted ? "true" : "false")}," + + $"\"uptimeSeconds\":{Uptime.TotalSeconds:F1}," + + $"\"connectionTimeUtc\":{E(ConnectionTime.ToString("O"))}" + + "}"; + } + + private string BuildStatusJson() + { + var E = HealthApiServer.JsonEscape; + return "{" + + "\"status\":\"ok\"," + + "\"type\":\"client\"," + + $"\"serverAddress\":{E(_config.Host + ":" + _config.Port)}," + + $"\"protocol\":{E(_config.Protocol.ToString())}," + + $"\"isConnected\":{(IsConnected ? "true" : "false")}," + + $"\"isSecure\":{(IsSecure ? "true" : "false")}," + + $"\"isEncrypted\":{(IsEncrypted ? "true" : "false")}," + + $"\"nickname\":{E(Nickname)}," + + $"\"bytesSent\":{BytesSent}," + + $"\"bytesReceived\":{BytesReceived}," + + $"\"messagesSent\":{MessagesSent}," + + $"\"messagesReceived\":{MessagesReceived}," + + $"\"uptimeSeconds\":{Uptime.TotalSeconds:F1}," + + $"\"connectionTimeUtc\":{E(ConnectionTime.ToString("O"))}," + + $"\"lastActiveUtc\":{(LastActive == default ? "null" : E(LastActive.ToString("O")))}," + + $"\"idleTimeSeconds\":{IdleTime().TotalSeconds:F1}," + + $"\"lastDataSentUtc\":{(LastDataSent == default ? "null" : E(LastDataSent.ToString("O")))}," + + $"\"lastDataReceivedUtc\":{(LastDataReceived == default ? "null" : E(LastDataReceived.ToString("O")))}," + + $"\"disconnectionTimeUtc\":{(DisconnectionTime == default ? "null" : E(DisconnectionTime.ToString("O")))}," + + $"\"isAutoConnectStarted\":{(IsAutoConnectStarted ? "true" : "false")}," + + $"\"totalErrors\":{StatusPage.TotalErrors}," + + $"\"sslErrors\":{StatusPage.SslErrorCount}" + + "}"; } public async Task ConnectAsync() { + DebugConnection($"Attempting to connect to {_config.Host}:{_config.Port} using protocol {_config.Protocol} with SSL: {_config.UseSsl} and AES: {_config.UseAesEncryption}"); await _connectLock.WaitAsync().ConfigureAwait(false); - + DebugConnection($"Acquired connection lock for {_config.Host}:{_config.Port}"); + try { - await CleanupAsync().ConfigureAwait(false); - _cancellation = new CancellationTokenSource(); - + if (_clientCts == null || _clientCts.IsCancellationRequested) + { + _clientCts?.Dispose(); + _clientCts = new CancellationTokenSource(); + } + + // Prevent duplicate connections if already connected + if (IsConnected) + { + if (_config.Protocol == ProtocolType.TCP) + { + if (_stream == null || !_stream.CanRead || !_stream.CanWrite) + { + await DisconnectClientAsync(DisconnectReason.RemoteClosed).ConfigureAwait(false); + } + else + { + return true; + } + } + else + { + if (_udpClient == null) + { + IsConnected = false; + } + else + { + return true; + } + } + } + + DebugConnection($"Starting connection process for {_config.Host}:{_config.Port}"); + DebugConnection($"Cleaned up existing connection for {_config.Host}:{_config.Port}"); + _connectionCts?.Cancel(); + _connectionCts?.Dispose(); + + _connectionCts = new CancellationTokenSource(); + + DebugConnection($"Connecting to {_config.Host}:{_config.Port} using protocol {_config.Protocol}"); + bool isConnected = false; + if (_config.Protocol == ProtocolType.TCP) { var result = await ConnectTcpAsync().ConfigureAwait(false); - + isConnected = result; if (!result) { throw new Exception("Failed to connect via TCP"); @@ -215,286 +336,508 @@ namespace EonaCat.Connections else { var result = await ConnectUdpAsync().ConfigureAwait(false); + isConnected = result; if (!result) { throw new Exception("Failed to connect via UDP"); } } - // If we already had a nickname, resend it - if (!string.IsNullOrEmpty(Nickname)) + if (isConnected) { - OnLog?.Invoke(this, "Resending nickname after reconnect"); - await SendNicknameAsync(Nickname).ConfigureAwait(false); + DebugConnection($"Successfully connected to {_config.Host}:{_config.Port}"); + + // If we already had a nickname, resend it + if (!string.IsNullOrEmpty(Nickname)) + { + await SendNicknameAsync(Nickname).ConfigureAwait(false); + } + + DebugConnection($"Starting background tasks for {_config.Host}:{_config.Port}"); + if (_config.EnableAutoReconnect && + (_autoReconnectTask == null || _autoReconnectTask.IsCompleted || _autoReconnectTask.IsCanceled || _autoReconnectTask.IsFaulted)) + { + if (_autoReconnectSource == null || _autoReconnectSource.IsCancellationRequested) + { + _autoReconnectSource?.Dispose(); + _autoReconnectSource = new CancellationTokenSource(); + } + + _autoReconnectTask = Task.Run(() => AutoReconnectAsync(_autoReconnectSource.Token), _autoReconnectSource.Token); + } + + _wasConnected = true; + } + + OnConnected?.Invoke(this, new ConnectionEventArgs + { + ClientId = Nickname ?? "self", + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) + }); + + if (_config.EnableAutoHtmlReports) + { + StatusPage.StartAutoHtmlReport( + _config.HtmlReportOutputDirectory, + _config.HtmlReportIntervalSeconds, + "status-client-errors.html"); + } + + if (_config.EnableHealthApi && !HealthApi.IsRunning) + { + HealthApi.Start(_config.HealthApiPort, _config.HealthApiBindAddress); } return true; } catch (Exception exception) { - _isConnected = false; - OnGeneralError?.Invoke(this, new ErrorEventArgs - { - Exception = exception, Message = "Connection error" - }); - - if (_config.EnableAutoReconnect) + RecordError(exception, "Connection error"); + OnGeneralError?.Invoke(this, new ErrorEventArgs { - _ = Task.Run(() => AutoReconnectAsync()); - } + Exception = exception, + Message = "Connection error" + }); return false; } finally { - try - { - _connectLock.Release(); + try + { + _connectLock?.Release(); } - catch + catch { // Do nothing } } } + private async Task CleanupExistingConnectionAsync() + { + try + { + _stream?.Close(); + _stream?.Dispose(); + _stream = null; + + if (_tcpClient != null) + { + try + { + _tcpClient?.Client?.Shutdown(SocketShutdown.Both); + } + catch + { + // Do nothing + } + + _tcpClient?.Close(); + _tcpClient?.Dispose(); + _tcpClient = null; + } + + if (_receiveTask != null) + { + await Task.WhenAny(_receiveTask, Task.Delay(3000)); + } + } + catch (Exception ex) + { + DebugConnection($"Error cleaning up existing connection for {_config.Host}:{_config.Port}: {ex.Message}"); + OnLog?.Invoke(this, $"Error cleaning up existing connection: {ex.Message}"); + } + } + + private async Task ConnectTcpAsync() + { + await _connectionAttemptLock.WaitAsync(); + + try + { + if (IsConnected) + { + return true; + } + + int attempt = 0; + + while (_config.SSLMaxRetries == 0 || attempt < _config.SSLMaxRetries) + { + if (_clientCts != null && _clientCts.IsCancellationRequested) + { + break; + } + + attempt++; + + TcpClient tcpClient = null; + SslHandshakeDiagnostics sslDiagnostics = null; + + try + { + tcpClient = new TcpClient + { + NoDelay = !_config.EnableNagle, + ReceiveBufferSize = _config.BufferSize, + SendBufferSize = _config.BufferSize, + LingerState = new LingerOption(_config.EnableRST, 0) + }; + + if (EnableKeepAlive) + { + DebugConnection($"Enabling TCP keep-alive for {_config.Host}:{_config.Port} with KeepAliveTimeSeconds: {_config.KeepAliveTimeSeconds}, KeepAliveIntervalSeconds: {_config.KeepAliveIntervalSeconds}, KeepAliveRetryCount: {_config.KeepAliveRetryCount}"); + ConfigureKeepAlive(tcpClient.Client, _config.KeepAliveTimeSeconds, _config.KeepAliveIntervalSeconds, _config.KeepAliveRetryCount); + } + + await CleanupExistingConnectionAsync().ConfigureAwait(false); + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(_clientCts.Token); + connectCts.CancelAfter(_config.ConnectionTimeout > TimeSpan.Zero ? _config.ConnectionTimeout : TimeSpan.FromSeconds(10)); +#if NET8_0_OR_GREATER + await tcpClient.ConnectAsync(_config.Host, _config.Port, connectCts.Token).ConfigureAwait(false); +#else + var connectTask = tcpClient.ConnectAsync(_config.Host, _config.Port); + var timeoutTask = Task.Delay(Timeout.Infinite, connectCts.Token); + if (await Task.WhenAny(connectTask, timeoutTask).ConfigureAwait(false) != connectTask) + { + throw new OperationCanceledException("TCP connect timed out."); + } + await connectTask.ConfigureAwait(false); +#endif + _tcpClient = tcpClient; + tcpClient = null; + + LastPongReceived = DateTime.UtcNow; + DebugConnection($"TCP connection established to {_config.Host}:{_config.Port} on attempt {attempt} using client {_tcpClient.Client.LocalEndPoint}"); + NetworkStream networkStream = _tcpClient.GetStream(); + DebugConnection($"Obtained network stream for {_config.Host}:{_config.Port} on attempt {attempt}, SSL Enabled: {_config.UseSsl}"); + + if (!_config.UseSsl) + { + _stream = networkStream; + break; + } + + // SSL setup + var sslStream = new SslStream(networkStream, leaveInnerStreamOpen: true, _config.GetRemoteCertificateValidationCallback()); + sslDiagnostics = _config.EnableSslDiagnostics ? new SslHandshakeDiagnostics() : null; + var remoteEndPoint = _tcpClient?.Client.RemoteEndPoint?.ToString() ?? "unknown"; + sslDiagnostics?.StartHandshake(remoteEndPoint, _config.Certificate != null); + +#if NET8_0_OR_GREATER + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = _config.Host, + ClientCertificates = _config.Certificate != null ? new X509CertificateCollection { _config.Certificate } : null, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + CertificateRevocationCheckMode = _config.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, + AllowRenegotiation = _config.AllowTlsRenegotiation + }; + DebugConnection($"Authenticating SSL connection to {_config.Host}:{_config.Port} with SNI: {_config.Host}, Client Certificate: {(_config.Certificate != null ? "Yes" : "No")}, Enabled Protocols: {clientOptions.EnabledSslProtocols}, Check Certificate Revocation: {_config.CheckCertificateRevocation}"); + sslDiagnostics?.StartStage("ClientAuth"); + await sslStream.AuthenticateAsClientAsync(clientOptions).ConfigureAwait(false); + sslDiagnostics?.EndStage("ClientAuth"); +#else + sslDiagnostics?.StartStage("ClientAuth"); + 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); + } + sslDiagnostics?.EndStage("ClientAuth"); + #endif + + DebugConnection($"SSL authentication successful for {_config.Host}:{_config.Port} using SNI: {_config.Host}, Client Certificate: {(_config.Certificate != null ? "Yes" : "No")}, Enabled Protocols: {(sslStream.SslProtocol)}, Cipher Algorithm: {sslStream.CipherAlgorithm}, Hash Algorithm: {sslStream.HashAlgorithm}, Key Exchange Algorithm: {sslStream.KeyExchangeAlgorithm}"); + sslDiagnostics?.RecordSuccess(sslStream); + if (sslDiagnostics != null) + { + DebugConnection($"[SSL Metrics] {sslDiagnostics}"); + } + _stream = sslStream; + break; + } + catch (SocketException socketEx) + { + var errorMsg = $"Socket error on TCP connect attempt {attempt}: {socketEx.SocketErrorCode} - {socketEx.SocketErrorCode switch { SocketError.ConnectionRefused => "Connection refused - server not listening or firewall blocked", SocketError.HostUnreachable => "Host unreachable - network unreachable or no route to host", SocketError.NetworkUnreachable => "Network unreachable", SocketError.TimedOut => "Connection attempt timed out", _ => "Unknown socket error" }}"; + RecordError(socketEx, errorMsg); + DebugConnection($"[Attempt {attempt}] Socket error: {socketEx.SocketErrorCode} - {socketEx.Message}"); + OnLog?.Invoke(this, $"[Attempt {attempt}] TCP connection failed (SocketError={socketEx.SocketErrorCode}): {socketEx.Message}, cannot connect to {_config.Host}:{_config.Port}"); + if (_config.SSLMaxRetries != 0 && attempt >= _config.SSLMaxRetries) + { + throw; + } + } + catch (IOException ioEx) when (ioEx.InnerException is SocketException innerSocketEx) + { + var errorMsg = $"Socket IO error on TCP connect attempt {attempt}: {innerSocketEx.SocketErrorCode} - {innerSocketEx.SocketErrorCode switch { SocketError.ConnectionRefused => "Connection refused", SocketError.HostUnreachable => "Host unreachable", _ => "Unknown socket error" }}"; + RecordError(ioEx, errorMsg); + DebugConnection($"[Attempt {attempt}] Socket IO error: {innerSocketEx.SocketErrorCode} - {ioEx.Message}"); + OnLog?.Invoke(this, $"[Attempt {attempt}] TCP connection failed (SocketError={innerSocketEx.SocketErrorCode}): {ioEx.Message}, cannot connect to {_config.Host}:{_config.Port}"); + if (_config.SSLMaxRetries != 0 && attempt >= _config.SSLMaxRetries) + { + throw; + } + } + catch (Exception ex) + { + var isRecoverable = _config.UseSsl ? Helpers.SslHandshakeDiagnostics.IsRecoverableFailure(ex) : false; + sslDiagnostics?.RecordFailure(ex, isRecoverable); + + RecordError(ex, $"Connection error on attempt {attempt}: {ex.GetType().Name} - {ex.Message}", isSslError: _config.UseSsl); + if (_config.UseSsl) + { + // Try to read SSL error notification from server + string serverSslError = null; + try + { + var rawStream = _tcpClient?.GetStream(); + if (rawStream != null && rawStream.DataAvailable) + { + var buf = new byte[1024]; + var n = await rawStream.ReadAsync(buf, 0, buf.Length).ConfigureAwait(false); + if (n > 0) + { + var msg = Encoding.UTF8.GetString(buf, 0, n); + if (msg.Contains(Configuration.SSL_ERROR_PREFIX)) + { + var startIdx = msg.IndexOf(Configuration.SSL_ERROR_PREFIX, StringComparison.Ordinal) + Configuration.SSL_ERROR_PREFIX.Length; + var endIdx = msg.IndexOf(Configuration.SSL_ERROR_SUFFIX, StringComparison.Ordinal); + if (endIdx > startIdx) + { + serverSslError = msg.Substring(startIdx, endIdx - startIdx); + } + } + } + } + } + catch (Exception readEx) + { + DebugConnection($"Unable to read SSL error notification from server: {readEx.GetType().Name}"); + } + + // Try to send SSL error notification to server + try + { + var rawStream = _tcpClient?.GetStream(); + if (rawStream != null) + { + var notification = Encoding.UTF8.GetBytes($"{Configuration.SSL_ERROR_PREFIX}Client SSL error: {ex.GetType().Name} - {ex.Message}{Configuration.SSL_ERROR_SUFFIX}"); + await rawStream.WriteAsync(notification, 0, notification.Length).ConfigureAwait(false); + await rawStream.FlushAsync().ConfigureAwait(false); + } + } + catch (Exception sendEx) + { + DebugConnection($"Unable to send SSL error notification to server: {sendEx.GetType().Name}"); + } + + OnSslError?.Invoke(this, new ErrorEventArgs + { + Exception = ex, + Message = serverSslError != null + ? $"SSL connection error on attempt {attempt}. Server reported: {serverSslError}" + : $"SSL connection error on attempt {attempt}: {ex.GetType().Name} - {ex.Message}" + }); + } + else + { + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = ex, + Message = $"TCP connection error on attempt {attempt}: {ex.GetType().Name} - {ex.Message}" + }); + } + OnLog?.Invoke(this, $"[Attempt {attempt}] TCP/SSL connection failed: {ex.GetType().Name} - {ex.Message}, cannot connect to {_config.Host}:{_config.Port}"); + if (_config.SSLMaxRetries != 0 && attempt >= _config.SSLMaxRetries) + { + throw; + } + } + finally + { + try { tcpClient?.Close(); tcpClient?.Dispose(); } catch { } + tcpClient = null; + } + await Task.Delay(_config.SSLRetryDelayInSeconds * 1000).ConfigureAwait(false); + } + + if (_config.UseAesEncryption) + { + _aesEncryption?.Dispose(); + _aesEncryption = await AesKeyExchange.ReceiveAesKeyAsync(_stream, _config.AesPassword).ConfigureAwait(false); + } + + IsConnected = true; + + // Start receive loop + _receiveTask = Task.Run(async () => await ReceiveDataAsync(_connectionCts.Token)); + + if (_config.EnableHeartbeat) + { + _pingTask = Task.Run(() => StartPingLoopAsync(_connectionCts.Token)); + _pongTask = Task.Run(() => StartPongLoop(_connectionCts.Token)); + } + + if (_config.IdleTimeoutSeconds > 0) + { + _idleMonitorTask = Task.Run(() => MonitorIdleTimeoutAsync(_connectionCts.Token)); + } + + return true; + } + finally + { + _connectionAttemptLock.Release(); + } + } + + private void UpdateLastActive() + { + LastActive = DateTime.UtcNow; + LastDataReceived = DateTime.UtcNow; + IsIdleTimeoutTriggered = false; + _lastIdleLogUtc = DateTime.MinValue; + } + + private async Task CancelAndWaitAsync() + { + _connectionCts?.Cancel(); + _connectionCts?.Dispose(); + _connectionCts = null; + + await Task.WhenAll( + SafeTaskCompletion(_pingTask), + SafeTaskCompletion(_pongTask), + SafeTaskCompletion(_receiveTask), + SafeTaskCompletion(_idleMonitorTask) + ); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + _clientCts?.Cancel(); + + await SafeTaskCompletion(_autoReconnectTask); + try + { + _autoReconnectTask?.Dispose(); + } + catch + { + // Do nothing + } + + _autoReconnectTask = null; + + await DisconnectClientAsync(); + await CleanupAsync(); + + _clientCts?.Dispose(); + _clientCts = null; + + _autoReconnectSource?.Cancel(); + _autoReconnectSource?.Dispose(); + _autoReconnectSource = null; + + await SafeTaskCompletion(_pingTask); + await SafeTaskCompletion(_pongTask); + await SafeTaskCompletion(_idleMonitorTask); + + _pingTask?.Dispose(); + _pongTask?.Dispose(); + _idleMonitorTask?.Dispose(); + + _pingTask = null; + _pongTask = null; + _idleMonitorTask = null; + + _connectLock?.Dispose(); + _connectionAttemptLock?.Dispose(); + _streamReadLock?.Dispose(); + _streamWriteLock?.Dispose(); + + HealthApi.Dispose(); + + OnConnected = null; + OnDisconnected = null; + OnDataReceived = null; + OnGeneralError = null; + OnPingResponse = null; + OnPongResponse = null; + OnSslError = null; + OnEncryptionError = null; + OnNicknameSend = null; + OnSocketError = null; + } + finally + { + GC.SuppressFinalize(this); + } + } + private async Task CleanupAsync() { - try - { - _cancellation?.Cancel(); - } - catch - { - // Do nothing - } - - try - { - _pingCancellation?.Cancel(); - } - catch - { - // Do nothing - } - - try - { - _pongCancellation?.Cancel(); - } - catch - { - // Do nothing - } + IsConnected = false; + await CancelAndWaitAsync(); if (_pingTask != null) { - try - { - await Task.WhenAny(_pingTask, Task.Delay(500)).ConfigureAwait(false); - } - catch - { - // Do nothing - } + await Task.WhenAny(_pingTask, Task.Delay(3000)); } - + if (_pongTask != null) { - try - { - await Task.WhenAny(_pongTask, Task.Delay(500)).ConfigureAwait(false); - } - catch - { - // Do nothing - } + await Task.WhenAny(_pongTask, Task.Delay(3000)); } - try + if (_idleMonitorTask != null) { - _cancellation?.Dispose(); - } - catch - { - // Do nothing + await Task.WhenAny(_idleMonitorTask, Task.Delay(3000)); } - try - { - _pingCancellation?.Dispose(); - } - catch - { - // Do nothing - } + _pingTask?.Dispose(); + _pongTask?.Dispose(); + _idleMonitorTask?.Dispose(); + _receiveTask?.Dispose(); - try - { - _pongCancellation?.Dispose(); - } - catch - { - // Do nothing - } - - _cancellation = null; - _pingCancellation = null; - _pongCancellation = null; _pingTask = null; _pongTask = null; + _idleMonitorTask = null; + _receiveTask = null; - try - { - _tcpClient?.Close(); - } - catch - { - // Do nothing - } - - try - { - _tcpClient?.Dispose(); - } - catch - { - // Do nothing - } + _tcpClient?.Close(); + _tcpClient?.Dispose(); _tcpClient = null; - try - { - _udpClient?.Close(); - } - catch - { - // Do nothing - } - - try - { - _udpClient?.Dispose(); - } - catch - { - // Do nothing - } + _udpClient?.Close(); + _udpClient?.Dispose(); _udpClient = null; - try - { - _stream?.Dispose(); - } - catch - { - // Do nothing - } + _stream?.Close(); + _stream?.Dispose(); _stream = null; - try - { - _aesEncryption?.Dispose(); - } - catch - { - // Do nothing - } + _utf8Decoder.Reset(); + _aesEncryption?.Dispose(); _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); - } - } - - if (_config.UseAesEncryption) - { - _aesEncryption = await AesKeyExchange.ReceiveAesKeyAsync(_stream, _config.AesPassword).ConfigureAwait(false); - } - - _isConnected = true; - ConnectionTime = DateTime.UtcNow; - OnConnected?.Invoke(this, new ConnectionEventArgs - { - ClientId = "self", - RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) - }); - - _ = Task.Run(() => ReceiveDataAsync(), _cancellation.Token); - - if (_config.EnableHeartbeat) - { - _ = 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; } @@ -504,71 +847,129 @@ namespace EonaCat.Connections public DateTime LastDataSent { get; private set; } public DateTime LastDataReceived { get; private set; } public DateTime DisconnectionTime { get; private set; } + public bool IsAutoConnectStarted { get; private set; } + public bool IsIdleTimeoutTriggered { get; private set; } + private DateTime _lastIdleLogUtc; + private CancellationTokenSource _autoReconnectSource; + public bool EnableKeepAlive { get; set; } = true; + + /// + /// Determines after how many seconds of idleness a reminder log is shown. + /// + public double ShowIdleReminderInSeconds { get; set; } = 20; + + /// + /// Occurs when a client has been idle for longer than the configured timeout period. + /// + /// Subscribers can use this event to perform actions such as disconnecting or notifying + /// idle clients. The event is raised with an instance containing details + /// about the idle client. + public event EventHandler OnIdleTimeout; private async Task ConnectUdpAsync() { try { + DebugConnection($"Attempting UDP connection to {_config.Host}:{_config.Port}"); + _udpClient = new UdpClient(); _udpClient.Client.ReceiveBufferSize = _config.BufferSize; _udpClient.Client.SendBufferSize = _config.BufferSize; - _udpClient.Connect(_config.Host, _config.Port); - _isConnected = true; + + try + { + _udpClient.Connect(_config.Host, _config.Port); + } + catch (SocketException socketEx) + { + var errorMsg = $"UDP connection failed: {socketEx.SocketErrorCode} - {socketEx.SocketErrorCode switch { SocketError.ConnectionRefused => "Connection refused - server not listening", SocketError.HostUnreachable => "Host unreachable", SocketError.NetworkUnreachable => "Network unreachable", _ => "Unknown socket error" }}"; + DebugConnection($"UDP connection socket error: {socketEx.SocketErrorCode} - {socketEx.Message}"); + RecordError(socketEx, errorMsg); + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = socketEx, Message = errorMsg }); + + try { _udpClient?.Close(); _udpClient?.Dispose(); } catch { } + _udpClient = null; + return false; + } + catch (Exception ex) + { + var errorMsg = $"UDP connection error: {ex.GetType().Name} - {ex.Message}"; + DebugConnection($"UDP connection failed: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, errorMsg); + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = errorMsg }); + + try { _udpClient?.Close(); _udpClient?.Dispose(); } catch { } + _udpClient = null; + return false; + } + ConnectionTime = DateTime.UtcNow; - OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); - _ = Task.Run(() => ReceiveUdpDataAsync(), _cancellation.Token); + LastPongReceived = DateTime.UtcNow; + UpdateLastActive(); + + DebugConnection($"UDP connection established to {_config.Host}:{_config.Port}"); + + _ = Task.Run(() => ReceiveUdpDataAsync(), _connectionCts.Token); if (_config.EnableHeartbeat) { - _ = Task.Run(StartPingLoop, _cancellation.Token); - _ = Task.Run(StartPongLoop, _cancellation.Token); + _pingTask = Task.Run(() => StartPingLoopAsync(_connectionCts.Token)); + _pongTask = Task.Run(() => StartPongLoop(_connectionCts.Token)); } + + if (_config.IdleTimeoutSeconds > 0) + { + _idleMonitorTask = Task.Run(() => MonitorIdleTimeoutAsync(_connectionCts.Token)); + } + + IsConnected = true; + OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); return true; } catch (Exception exception) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = "UDP connection error" }); + DebugConnection($"Unexpected error during UDP connection: {exception.GetType().Name} - {exception.Message}"); + RecordError(exception, $"UDP connection error: {exception.GetType().Name} - {exception.Message}"); + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = $"UDP connection error: {exception.GetType().Name}" }); + + try { _udpClient?.Close(); _udpClient?.Dispose(); } catch { } + _udpClient = null; return false; } } - private void StartPingLoop() + private async Task StartPingLoopAsync(CancellationToken token) { - try - { - _pingCancellation?.Cancel(); - } - catch - { - // Do nothing - } + var interval = TimeSpan.FromSeconds(_config.HeartbeatIntervalSeconds); + var next = DateTime.UtcNow + interval; - _pingCancellation?.Dispose(); - _pingCancellation = new CancellationTokenSource(); - var token = _pingCancellation.Token; - - _pingTask = Task.Run(async () => + try { - var interval = TimeSpan.FromSeconds(_config.HeartbeatIntervalSeconds); - var next = DateTime.UtcNow + interval; - - while (!token.IsCancellationRequested && _isConnected) + while (!token.IsCancellationRequested && IsConnected) { try { if (_stream == null || !_stream.CanWrite) { + DebugConnection("Ping loop stopping - stream not available for writing"); break; } - var pingData = Encoding.UTF8.GetBytes(Configuration.PING_VALUE); - await WriteToStreamAsync(pingData).ConfigureAwait(false); + var result = await SendInternalAsync(PingBytes, token).ConfigureAwait(false); - if (_config.EnablePingPongLogs) + if (result) { - OnLog?.Invoke(this, $"[PING] Sent at {DateTime.UtcNow:O}"); + LastPingSent = DateTime.UtcNow; + if (_config.EnablePingPongLogs) + { + OnLog?.Invoke(this, $"[PING] Sent at {DateTime.UtcNow:O}"); + } + } + else + { + DebugConnection("Ping loop failed to send ping - will retry on next interval"); } var delay = next - DateTime.UtcNow; @@ -581,53 +982,56 @@ namespace EonaCat.Connections } catch (OperationCanceledException) { + DebugConnection($"Ping loop: Operation canceled for {_config.Host}:{_config.Port}"); break; } catch (Exception exception) { - OnLog?.Invoke(this, $"[PING] Error sending ping: {exception.Message}"); + DebugConnection($"Ping loop: Error sending ping to {_config.Host}:{_config.Port}: {exception.GetType().Name} - {exception.Message}"); + RecordError(exception, $"Ping loop error: {exception.GetType().Name} - {exception.Message}"); + OnLog?.Invoke(this, $"[PING] Error sending ping: {exception.GetType().Name} - {exception.Message}"); break; } } - }, token); + } + catch (Exception ex) + { + DebugConnection($"Ping loop: Unexpected error: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Ping loop unexpected error: {ex.GetType().Name}"); + } + finally + { + DebugConnection("Ping loop stopped."); + } } - private void StartPongLoop() + private async Task StartPongLoop(CancellationToken token) { - _pongCancellation?.Cancel(); - _pongCancellation?.Dispose(); + var interval = TimeSpan.FromSeconds(1); + var next = DateTime.UtcNow + interval; - _pongCancellation = new CancellationTokenSource(); - var token = _pongCancellation.Token; - - _pongTask = Task.Run(async () => + try { - var interval = TimeSpan.FromSeconds(1); - var next = DateTime.UtcNow + interval; - - while (!token.IsCancellationRequested && _isConnected) + while (!token.IsCancellationRequested) { try { + if (!IsConnected) + { + DebugConnection("Pong loop stopping - client disconnected"); + break; + } + var elapsed = (DateTime.UtcNow - LastPongReceived).TotalSeconds; - if (LastPongReceived != DateTime.MinValue && elapsed > _config.HeartbeatIntervalSeconds * 2) + if (LastPongReceived != DateTime.MinValue && + elapsed > _config.HeartbeatIntervalSeconds * 5) { - 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) { + var msg = $"No PONG received for {elapsed:F1}s (threshold: {_config.HeartbeatIntervalSeconds * 5}s) - disconnecting due to unresponsive server"; + DebugConnection($"Pong monitor: {msg}"); + RecordError(null, msg); await DisconnectClientAsync(DisconnectReason.NoPongReceived); break; } @@ -636,255 +1040,713 @@ namespace EonaCat.Connections var delay = next - DateTime.UtcNow; if (delay > TimeSpan.Zero) { - await Task.Delay(delay, token).ConfigureAwait(false); + await Task.Delay(delay, token); } next += interval; } - catch (TaskCanceledException) + catch (OperationCanceledException) { + DebugConnection($"Pong loop: Operation canceled for {_config.Host}:{_config.Port}"); break; } - catch (Exception exception) + catch (Exception ex) { - OnGeneralError?.Invoke(this, new ErrorEventArgs - { - Exception = exception, - Message = "Client pong watchdog failed" - }); + DebugConnection($"Pong loop: Error monitoring pong for {_config.Host}:{_config.Port}: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Pong loop error: {ex.GetType().Name} - {ex.Message}"); + await DisconnectClientAsync(DisconnectReason.Error, ex); + break; } } - }, token); - } - - - private async Task ReceiveDataAsync() - { - var pooled = ArrayPool.Shared.Rent(_config.BufferSize); - var assemblyBuffer = new List(_config.BufferSize * 2); - try + } + catch (Exception ex) { - while (_cancellation != null && !_cancellation.Token.IsCancellationRequested && _isConnected) - { - int bytesRead; - - try - { - 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); - } - } + DebugConnection($"Pong loop: Unexpected error: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Pong loop unexpected error: {ex.GetType().Name}"); } finally { - ArrayPool.Shared.Return(pooled, clearArray: true); + DebugConnection("Pong loop stopped."); } } - private byte[] BuildMessage(List buffer) + private async Task MonitorIdleTimeoutAsync(CancellationToken token) { - if (buffer == null || buffer.Count == 0) + var checkInterval = TimeSpan.FromSeconds(1); + + while (!token.IsCancellationRequested && IsConnected) { - return null; + try + { + var idleTime = IdleTime(); + var idleSeconds = idleTime.TotalSeconds; + + // Check if idle timeout threshold is exceeded + if (_config.IdleTimeoutSeconds > 0 && idleSeconds >= _config.IdleTimeoutSeconds) + { + if (!IsIdleTimeoutTriggered) + { + IsIdleTimeoutTriggered = true; + + DebugConnection($"Idle timeout triggered: {idleSeconds:F1}s >= {_config.IdleTimeoutSeconds}s"); + + OnIdleTimeout?.Invoke(this, new IdleClientEventArgs( + _config.IdleTimeoutSeconds, + this, + $"Client idle for {IdleTimeFormatted(includeMilliseconds: false)}" + )); + } + } + + // Show idle reminder log if configured + if (ShowIdleReminderInSeconds > 0 && idleSeconds >= ShowIdleReminderInSeconds) + { + var now = DateTime.UtcNow; + if ((now - _lastIdleLogUtc).TotalSeconds >= ShowIdleReminderInSeconds) + { + _lastIdleLogUtc = now; + DebugConnection($"Client idle for {IdleTimeFormatted(includeMilliseconds: false)}"); + } + } + + await Task.Delay(checkInterval, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + DebugConnection($"MonitorIdleTimeoutAsync: Operation canceled for {_config.Host}:{_config.Port}"); + break; + } + catch (Exception ex) + { + DebugConnection($"MonitorIdleTimeoutAsync: Error monitoring idle timeout for {_config.Host}:{_config.Port}: {ex.Message}"); + OnLog?.Invoke(this, $"[IDLE] Error monitoring idle timeout: {ex.Message}"); + break; + } } - - 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) + private async Task ReceiveDataAsync(CancellationToken token) { - if (delimiter == null || delimiter.Length == 0 || buffer.Count < delimiter.Length) + DebugConnection($"ReceiveDataAsync started for {_config.Host}:{_config.Port} with framing mode {_config.MessageFraming}"); + + try + { + switch (_config.MessageFraming) + { + case FramingMode.LengthPrefixed: + await ReceiveLengthPrefixedAsync(token); + break; + + case FramingMode.Delimiter: + await ReceiveDelimiterAsync(token); + break; + + case FramingMode.None: + await ReceiveNoneAsync(token); + break; + } + } + catch (OperationCanceledException ex) + { + DebugConnection("Receive loop cancelled by cancellation token."); + RecordError(ex, "Receive loop cancellation requested"); + } + catch (SocketException socketEx) + { + var errorMsg = $"Socket error in receive loop: {socketEx.SocketErrorCode} - {socketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset by remote", SocketError.ConnectionAborted => "Connection aborted", SocketError.TimedOut => "Receive timeout", _ => "Unknown socket error" }}"; + RecordError(socketEx, errorMsg); + DebugConnection($"Receive loop socket error: {socketEx.SocketErrorCode} - {socketEx.Message}"); + var reason = socketEx.SocketErrorCode == SocketError.ConnectionReset || socketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(reason, socketEx); + } + catch (IOException ioEx) when (ioEx.InnerException is SocketException innerSocketEx) + { + var errorMsg = $"Socket IO error in receive loop: {innerSocketEx.SocketErrorCode} - {innerSocketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset", SocketError.ConnectionAborted => "Connection aborted", _ => "Unknown socket error" }}"; + RecordError(ioEx, errorMsg); + DebugConnection($"Receive loop socket IO error: {innerSocketEx.SocketErrorCode} - {ioEx.Message}"); + var reason = innerSocketEx.SocketErrorCode == SocketError.ConnectionReset + || innerSocketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(reason, ioEx); + } + catch (IOException ioEx) + { + DebugConnection($"IO error in receive loop: {ioEx.GetType().Name} - {ioEx.Message}"); + RecordError(ioEx, $"IO error in receive loop: {ioEx.GetType().Name}"); + await DisconnectClientAsync(DisconnectReason.Error, ioEx); + } + catch (Exception ex) + { + DebugConnection($"Unexpected error in receive loop: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Receive loop error: {ex.GetType().Name} - {ex.Message}"); + await DisconnectClientAsync(DisconnectReason.Error, ex); + } + finally + { + DebugConnection("Receive loop exited."); + } + } + + private async Task ReceiveLengthPrefixedAsync(CancellationToken token) + { + int prefixSize = _config.LengthPrefixedLength; + + if (prefixSize < 1 || prefixSize > 8) + { + throw new InvalidOperationException("LengthPrefixedLength must be between 1 and 8."); + } + + byte[] lengthBuffer = ArrayPool.Shared.Rent(prefixSize); + + try + { + while (!token.IsCancellationRequested && IsConnected && _stream?.CanRead == true) + { + await ReadExactAsync(_stream, lengthBuffer, 0, prefixSize).ConfigureAwait(false); + + long length = ParseLengthPrefix(lengthBuffer, prefixSize, _config.UseBigEndian); + + if (length <= 0 || length > _config.MAX_MESSAGE_SIZE) + { + throw new InvalidOperationException($"Invalid message length {length}"); + } + + byte[] buffer = ArrayPool.Shared.Rent((int)length); + + try + { + await ReadExactAsync(_stream, buffer, 0, (int)length).ConfigureAwait(false); + + if (_config.UseAesEncryption && _aesEncryption != null) + { + var decrypted = await AesKeyExchange.DecryptDataAsync(buffer.AsSpan(0, (int)length).ToArray(), (int)length, _aesEncryption).ConfigureAwait(false); + await ProcessReceivedDataAsync(decrypted).ConfigureAwait(false); + } + else + { + await ProcessReceivedDataAsync(buffer.AsMemory(0, (int)length)).ConfigureAwait(false); + } + + UpdateLastActive(); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + catch (OperationCanceledException ex) + { + DebugConnection($"Length-prefixed read timeout: {ex.Message}"); + RecordError(ex, "Read timeout during length-prefixed receive - stream may have hung"); + await DisconnectClientAsync(DisconnectReason.Timeout, ex); + } + catch (SocketException socketEx) + { + DebugConnection($"Socket error in length-prefixed receive: {socketEx.SocketErrorCode} - {socketEx.Message}"); + RecordError(socketEx, $"Socket error in length-prefixed receive: {socketEx.SocketErrorCode} - {socketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset by remote", SocketError.ConnectionAborted => "Connection aborted", _ => "Unknown socket error" }}"); + var reason = socketEx.SocketErrorCode == SocketError.ConnectionReset || socketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(reason, socketEx); + } + catch (IOException ex) when (ex.InnerException is SocketException innerSocketEx) + { + DebugConnection($"Socket IO error in length-prefixed receive: {innerSocketEx.SocketErrorCode} - {ex.Message}"); + RecordError(ex, $"Socket IO error in length-prefixed receive: {innerSocketEx.SocketErrorCode} - {innerSocketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset", SocketError.ConnectionAborted => "Connection aborted", _ => "Unknown socket error" }}"); + var reason = innerSocketEx.SocketErrorCode == SocketError.ConnectionReset + || innerSocketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(reason, ex); + } + catch (IOException ex) + { + DebugConnection($"Connection closed during length-prefixed receive: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, "Connection closed during length-prefixed receive"); + await DisconnectClientAsync(DisconnectReason.RemoteClosed, ex); + } + catch (Exception ex) + { + DebugConnection($"Unexpected error in length-prefixed receive: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Unexpected error in length-prefixed receive: {ex.GetType().Name} - {ex.Message}"); + await DisconnectClientAsync(DisconnectReason.Error, ex); + } + finally + { + ArrayPool.Shared.Return(lengthBuffer); + } + } + + private static long ParseLengthPrefix(byte[] buffer, int length, bool useBigEndian) + { + long value = 0; + + if (useBigEndian) + { + for (int i = 0; i < length; i++) + { + value = (value << 8) | buffer[i]; + } + } + else + { + for (int i = 0; i < length; i++) + { + value |= (long)buffer[i] << (8 * i); + } + } + + return value; + } + + private async Task ReceiveNoneAsync(CancellationToken token) + { + byte[] buffer = ArrayPool.Shared.Rent(_config.BufferSize); + + try + { + while (!token.IsCancellationRequested && IsConnected && _stream?.CanRead == true) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + cts.CancelAfter(TimeSpan.FromSeconds(_config.ReadTimeoutSeconds)); + + int read = await _stream.ReadAsync(buffer, 0, buffer.Length, cts.Token); + + if (read == 0) + { + DebugConnection("Remote closed connection."); + await DisconnectClientAsync(DisconnectReason.RemoteClosed); + return; + } + + ReadOnlyMemory payload = buffer.AsMemory(0, read); + + if (_config.UseAesEncryption && _aesEncryption != null) + { + try + { + var decrypted = await AesKeyExchange.DecryptDataAsync(payload.ToArray(), read, _aesEncryption); + await ProcessReceivedDataAsync(decrypted); + } + catch (Exception ex) + { + DebugConnection($"Decrypt error: {ex.Message}"); + OnEncryptionError?.Invoke(this, new ErrorEventArgs + { + Exception = ex, + Message = "Error decrypting data" + }); + } + } + else + { + await ProcessReceivedDataAsync(payload); + } + + UpdateLastActive(); + } + } + catch (OperationCanceledException ex) + { + DebugConnection($"Read timeout during raw receive: {ex.Message}"); + RecordError(ex, "Read timeout in raw receive - stream may be unresponsive"); + await DisconnectClientAsync(DisconnectReason.Timeout, ex); + } + catch (SocketException socketEx) + { + DebugConnection($"Socket error in raw receive: {socketEx.SocketErrorCode} - {socketEx.Message}"); + var errorMsg = $"Socket error in raw receive: {socketEx.SocketErrorCode} - {socketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset", SocketError.ConnectionAborted => "Connection aborted", _ => "Unknown socket error" }}"; + RecordError(socketEx, errorMsg); + var reason = socketEx.SocketErrorCode == SocketError.ConnectionReset || socketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(reason, socketEx); + } + catch (IOException ex) when (ex.InnerException is SocketException innerSocketEx) + { + DebugConnection($"Socket IO error in raw receive: {innerSocketEx.SocketErrorCode} - {ex.Message}"); + var errorMsg = $"Socket IO error in raw receive: {innerSocketEx.SocketErrorCode} - {innerSocketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset", SocketError.ConnectionAborted => "Connection aborted", _ => "Unknown socket error" }}"; + RecordError(ex, errorMsg); + var reason = innerSocketEx.SocketErrorCode == SocketError.ConnectionReset + || innerSocketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(reason, ex); + } + catch (IOException ex) + { + DebugConnection($"Connection closed during raw receive: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, "Connection closed during raw receive"); + await DisconnectClientAsync(DisconnectReason.RemoteClosed, ex); + } + catch (Exception ex) + { + DebugConnection($"Unexpected error in raw receive: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Error in raw receive: {ex.GetType().Name} - {ex.Message}"); + await DisconnectClientAsync(DisconnectReason.Error, ex); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private async Task ReceiveDelimiterAsync(CancellationToken token) + { + byte[] buffer = ArrayPool.Shared.Rent(_config.BufferSize); + var memory = new MemoryStream(); + + try + { + while (!token.IsCancellationRequested && IsConnected && _stream?.CanRead == true) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + cts.CancelAfter(TimeSpan.FromSeconds(_config.ReadTimeoutSeconds)); + + int read = await _stream.ReadAsync(buffer, 0, buffer.Length, cts.Token); + + if (read == 0) + { + DebugConnection("Remote closed connection."); + await DisconnectClientAsync(DisconnectReason.RemoteClosed); + return; + } + + memory.Write(buffer, 0, read); + + if (memory.Length > _config.MAX_MESSAGE_SIZE) + { + throw new InvalidOperationException("Message too large."); + } + + while (TryExtractMessage(memory, _config.Delimiter, out var message)) + { + if (_config.UseAesEncryption && _aesEncryption != null) + { + message = await AesKeyExchange.DecryptDataAsync(message, message.Length, _aesEncryption); + } + + await ProcessReceivedDataAsync(message); + UpdateLastActive(); + } + } + } + catch (OperationCanceledException ex) + { + DebugConnection($"Read timeout during delimiter receive: {ex.Message}"); + RecordError(ex, "Read timeout in delimiter receive - stream may be unresponsive"); + await DisconnectClientAsync(DisconnectReason.Timeout, ex); + } + catch (SocketException socketEx) + { + DebugConnection($"Socket error in delimiter receive: {socketEx.SocketErrorCode} - {socketEx.Message}"); + var errorMsg = $"Socket error in delimiter receive: {socketEx.SocketErrorCode} - {socketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset", SocketError.ConnectionAborted => "Connection aborted", _ => "Unknown socket error" }}"; + RecordError(socketEx, errorMsg); + var reason = socketEx.SocketErrorCode == SocketError.ConnectionReset || socketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(reason, socketEx); + } + catch (IOException ex) when (ex.InnerException is SocketException innerSocketEx) + { + DebugConnection($"Socket IO error in delimiter receive: {innerSocketEx.SocketErrorCode} - {ex.Message}"); + var errorMsg = $"Socket IO error in delimiter receive: {innerSocketEx.SocketErrorCode} - {innerSocketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset", SocketError.ConnectionAborted => "Connection aborted", _ => "Unknown socket error" }}"; + RecordError(ex, errorMsg); + var reason = innerSocketEx.SocketErrorCode == SocketError.ConnectionReset + || innerSocketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(reason, ex); + } + catch (IOException ex) + { + DebugConnection($"Connection closed during delimiter receive: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, "Connection closed during delimiter receive"); + await DisconnectClientAsync(DisconnectReason.RemoteClosed, ex); + } + catch (Exception ex) + { + DebugConnection($"Unexpected error in delimiter receive: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Error in delimiter receive: {ex.GetType().Name} - {ex.Message}"); + await DisconnectClientAsync(DisconnectReason.Error, ex); + } + finally + { + ArrayPool.Shared.Return(buffer); + memory.Dispose(); + } + } + + private static bool TryExtractMessage(MemoryStream stream, byte[] delimiter, out byte[] message) + { + var buffer = stream.GetBuffer(); + int length = (int)stream.Length; + + int index = IndexOfDelimiter(buffer, length, delimiter); + + if (index < 0) + { + message = null; + return false; + } + + message = new byte[index]; + Buffer.BlockCopy(buffer, 0, message, 0, index); + + int remaining = length - index - delimiter.Length; + + Buffer.BlockCopy(buffer, index + delimiter.Length, buffer, 0, remaining); + stream.SetLength(remaining); + stream.Position = remaining; + + return true; + } + + private static int IndexOfDelimiter(byte[] buffer, int length, byte[] delimiter) + { + if (delimiter == null || delimiter.Length == 0 || length < delimiter.Length) { return -1; } - for (int i = 0; i <= buffer.Count - delimiter.Length; i++) + for (int i = 0; i <= length - delimiter.Length; i++) { bool match = true; + for (int j = 0; j < delimiter.Length; j++) { - if (buffer[i + j] != delimiter[j]) { match = false; break; } + if (buffer[i + j] != delimiter[j]) + { + match = false; + break; + } } + if (match) { return i; } } + return -1; } - private async Task ProcessReceivedDataAsync(byte[] data) + public async Task SendAsync(byte[] data) { - if (data == null || data.Length == 0) + try + { + byte[] payload = data; + + if (_config.UseAesEncryption && _aesEncryption != null) + { + payload = await AesKeyExchange.EncryptDataAsync(data, data.Length, _aesEncryption); + } + + byte[] framed = Frame(payload); + + return await SendInternalAsync(framed, _connectionCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + await DisconnectClientAsync(DisconnectReason.Error, ex); + return false; + } + } + + private byte[] Frame(byte[] payload) + { + switch (_config.MessageFraming) + { + case FramingMode.LengthPrefixed: + return CreateLengthPrefixed(payload); + + case FramingMode.Delimiter: + return Combine(payload, _config.Delimiter); + + case FramingMode.None: + default: + return payload; + } + } + + private static byte[] Combine(byte[] payload, byte[] delimiter) + { + if (delimiter == null || delimiter.Length == 0) + { + return payload; + } + + var result = new byte[payload.Length + delimiter.Length]; + + Buffer.BlockCopy(payload, 0, result, 0, payload.Length); + Buffer.BlockCopy(delimiter, 0, result, payload.Length, delimiter.Length); + + return result; + } + + private byte[] CreateLengthPrefixed(byte[] payload) + { + int prefixSize = _config.LengthPrefixedLength; + + if (prefixSize < 1 || prefixSize > 8) + { + throw new InvalidOperationException("LengthPrefixedLength must be between 1 and 8 bytes."); + } + + long length = payload.Length; + + // Validate that the payload fits into the configured prefix size + long maxValue = (1L << (prefixSize * 8)) - 1; + if (length > maxValue) + { + throw new InvalidOperationException( + $"Payload too large for {prefixSize}-byte length prefix."); + } + + byte[] result = new byte[prefixSize + payload.Length]; + + if (_config.UseBigEndian) + { + for (int i = 0; i < prefixSize; i++) + { + result[prefixSize - 1 - i] = (byte)(length & 0xFF); + length >>= 8; + } + } + else + { + for (int i = 0; i < prefixSize; i++) + { + result[i] = (byte)(length & 0xFF); + length >>= 8; + } + } + + Buffer.BlockCopy(payload, 0, result, prefixSize, payload.Length); + + return result; + } + + private static async Task ReadExactAsync(Stream stream, byte[] buffer, int offset, int count) + { + int totalRead = 0; + + while (totalRead < count) + { + int read = await stream.ReadAsync(buffer, offset + totalRead, count - totalRead).ConfigureAwait(false); + + if (read == 0) + { + throw new IOException("Remote socket closed while reading."); + } + + totalRead += read; + } + } + + private async Task ProcessReceivedDataAsync(ReadOnlyMemory data) + { + if (data.IsEmpty) { return; } + var now = DateTime.UtcNow; + LastDataReceived = now; + Interlocked.Add(ref _bytesReceived, data.Length); + Interlocked.Increment(ref _messagesReceived); + UpdateLastActive(); + + bool needsText = _config.EnableHeartbeat || OnDataReceived != null; + string stringData = null; - bool isBinary = true; + bool isText = false; - int realLength = Array.FindLastIndex(data, b => b != 0) + 1; - if (realLength > 0 && realLength < data.Length) + if (needsText) { - 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) + try { - isBinary = false; +#if NET8_0_OR_GREATER + var span = data.Span; + int charCount = Encoding.UTF8.GetCharCount(span); + + if (_charBuffer.Length < charCount) + { + _charBuffer = new char[charCount]; + } + + int written = Encoding.UTF8.GetChars(span, _charBuffer); +#else + byte[] bytes = data.ToArray(); + int charCount = _utf8Decoder.GetCharCount(bytes, 0, bytes.Length); + + if (_charBuffer.Length < charCount) + { + _charBuffer = new char[charCount]; + } + + int written = _utf8Decoder.GetChars(bytes, 0, bytes.Length, _charBuffer, 0); +#endif + if (written > 0) + { + stringData = new string(_charBuffer, 0, written); + isText = true; + } + } + catch + { + isText = false; } } - catch - { - // Not a valid UTF-8 string - } - if (!string.IsNullOrEmpty(stringData)) + // Heartbeat + if (_config.EnableHeartbeat && isText) { - LastPongReceived = DateTime.UtcNow; ProcessPingPong(ref stringData); + if (string.IsNullOrEmpty(stringData)) { return; } } + // SSL error notification from server + if (isText && !string.IsNullOrEmpty(stringData) && stringData.Contains(Configuration.SSL_ERROR_PREFIX, StringComparison.OrdinalIgnoreCase)) + { + string sslError = null; + var startIdx = stringData.IndexOf(Configuration.SSL_ERROR_PREFIX, StringComparison.Ordinal) + Configuration.SSL_ERROR_PREFIX.Length; + var endIdx = stringData.IndexOf(Configuration.SSL_ERROR_SUFFIX, StringComparison.Ordinal); + if (endIdx > startIdx) + { + sslError = stringData.Substring(startIdx, endIdx - startIdx); + } + RecordError(null, $"Server reported SSL error: {sslError ?? stringData}", isSslError: true); + OnSslError?.Invoke(this, new ErrorEventArgs + { + Message = $"Server reported SSL error: {sslError ?? stringData}" + }); + await DisconnectClientAsync(DisconnectReason.SSLError).ConfigureAwait(false); + 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}"); - } + OnLog?.Invoke(this, $"[DEBUG DATA] Received {data.Length} bytes"); } - if (!string.IsNullOrEmpty(stringData)) + OnDataReceived?.Invoke(this, new DataReceivedEventArgs { - 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, - }); - } + ClientId = "server", + Data = data.ToArray(), + IsBinary = !isText, + Timestamp = DateTime.UtcNow, + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port), + Nickname = Nickname, + }); } private void ProcessPingPong(ref string message) @@ -901,7 +1763,15 @@ namespace EonaCat.Connections { OnLog?.Invoke(this, "Received PING from server. Sending PONG response."); } - SendAsync(Configuration.PONG_VALUE).ConfigureAwait(false); + + OnPingResponse?.Invoke(this, new PingEventArgs + { + Id = "server", + Nickname = Nickname, + ReceivedTime = DateTime.UtcNow, + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) + }); + _ = SendAsync(Configuration.PONG_VALUE).ConfigureAwait(false); message = message.Replace(Configuration.PING_VALUE, string.Empty); } @@ -912,7 +1782,7 @@ namespace EonaCat.Connections { OnLog?.Invoke(this, "Received PONG from server for PING"); } - OnPingResponse?.Invoke(this, new PingEventArgs + OnPongResponse?.Invoke(this, new PingEventArgs { Id = "server", Nickname = Nickname, @@ -923,30 +1793,17 @@ namespace EonaCat.Connections } } - 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 != null && !_cancellation.Token.IsCancellationRequested && _isConnected) + DebugConnection($"UDP receive loop started for {_config.Host}:{_config.Port}"); + + while (_connectionCts != null && !_connectionCts.Token.IsCancellationRequested && IsConnected) { try { var result = await _udpClient.ReceiveAsync().ConfigureAwait(false); var buffer = result.Buffer; - + if (_config.EnableHeartbeat) { try @@ -967,20 +1824,46 @@ namespace EonaCat.Connections } catch (Exception exception) { + DebugConnection($"Heartbeat handling error in UDP: {exception.GetType().Name} - {exception.Message}"); + RecordError(exception, $"Heartbeat handling failed (UDP): {exception.GetType().Name} - {exception.Message}"); OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = "Heartbeat handling failed (UDP)" }); } } + + UpdateLastActive(); await ProcessReceivedDataAsync(buffer).ConfigureAwait(false); } + catch (SocketException socketEx) + { + var errorMsg = $"Socket error in UDP receive: {socketEx.SocketErrorCode} - {socketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset", SocketError.HostUnreachable => "Host unreachable", _ => "Unknown socket error" }}"; + DebugConnection($"UDP receive socket error: {socketEx.SocketErrorCode} - {socketEx.Message}"); + RecordError(socketEx, errorMsg); + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = socketEx, Message = errorMsg }); + ConnectionTime = DateTime.MinValue; + break; + } + catch (ObjectDisposedException ex) + { + DebugConnection("UDP receive loop: UDP client disposed, stopping receive loop."); + RecordError(ex, "UDP client disposed - connection closing"); + break; + } + catch (OperationCanceledException) + { + DebugConnection("UDP receive loop: Operation canceled."); + break; + } catch (Exception exception) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = "Error receiving data" }); - _isConnected = false; + DebugConnection($"UDP receive error: {exception.GetType().Name} - {exception.Message}"); + RecordError(exception, $"Error receiving UDP data: {exception.GetType().Name} - {exception.Message}"); + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = $"UDP receive error: {exception.GetType().Name}" }); ConnectionTime = DateTime.MinValue; - _ = Task.Run(() => AutoReconnectAsync()); break; } } + + DebugConnection("UDP receive loop stopped."); } public async Task SendNicknameAsync(string nickname) @@ -1006,262 +1889,494 @@ namespace EonaCat.Connections public async Task SendAsync(string message) => await SendAsync(Encoding.UTF8.GetBytes(message)).ConfigureAwait(false); - public async Task SendAsync(byte[] data) + public async Task SendAndWaitForResponseAsync(byte[] data, TimeSpan? timeout = null) { - if (!_isConnected) + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(DEFAULT_WAITING_TIMEOUT_IN_SECONDS); + + void Handler(object sender, DataReceivedEventArgs e) { - return false; + tcs.TrySetResult(e); } - await _sendLock.WaitAsync().ConfigureAwait(false); - + OnDataReceived += Handler; try { - byte[] payload = data; - if (_config.UseAesEncryption && _aesEncryption != null) + var sent = await SendAsync(data).ConfigureAwait(false); + if (!sent) { - await AesKeyExchange.EncryptDataAsync(payload, payload.Length, _aesEncryption).ConfigureAwait(false); + return null; } - byte[] framedData = null; - switch (_config.MessageFraming) + using var cts = new CancellationTokenSource(effectiveTimeout); + using var registration = cts.Token.Register(() => tcs.TrySetCanceled()); + + try { - 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; + return await tcs.Task.ConfigureAwait(false); } - - if (DEBUG_DATA_SEND) + catch (OperationCanceledException) { - OnLog?.Invoke(this, $"[DEBUG] Sending raw: {BitConverter.ToString(data)}"); - OnLog?.Invoke(this, $"[DEBUG] Sending framed: {BitConverter.ToString(framedData)}"); + return null; } - return await WriteToStreamAsync(framedData).ConfigureAwait(false); - } - catch (Exception exception) - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = "Error sending data" }); - return false; } finally { - try + OnDataReceived -= Handler; + } + } + + public async Task SendAndWaitForResponseAsync(string message, TimeSpan? timeout = null) + => await SendAndWaitForResponseAsync(Encoding.UTF8.GetBytes(message), timeout).ConfigureAwait(false); + + public async Task SendNicknameAndWaitForResponseAsync(string nickname, TimeSpan? timeout = null) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(DEFAULT_WAITING_TIMEOUT_IN_SECONDS); + + void Handler(object sender, DataReceivedEventArgs e) + { + tcs.TrySetResult(e); + } + + OnDataReceived += Handler; + try + { + var sent = await SendNicknameAsync(nickname).ConfigureAwait(false); + if (!sent) { - _sendLock.Release(); + return null; } - catch + + using var cts = new CancellationTokenSource(effectiveTimeout); + using var registration = cts.Token.Register(() => tcs.TrySetCanceled()); + + try { - // Do nothing + return await tcs.Task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return null; + } + } + finally + { + OnDataReceived -= Handler; + } + } + + private async Task SendInternalAsync(byte[] data, CancellationToken token) + { + if (!IsConnected) + { + return false; + } + + if (_config.Protocol == ProtocolType.TCP) + { + if (_stream == null || !_stream.CanWrite) + { + DebugConnection($"SendInternalAsync: Stream is not available for writing for {_config.Host}:{_config.Port}, disconnecting"); + await DisconnectClientAsync(DisconnectReason.RemoteClosed).ConfigureAwait(false); + return false; + } + + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds)); + + await _streamWriteLock.WaitAsync(cts.Token).ConfigureAwait(false); + try + { + await _stream.WriteAsync(data, 0, data.Length, cts.Token).ConfigureAwait(false); + await _stream.FlushAsync(cts.Token).ConfigureAwait(false); + + LastDataSent = DateTime.UtcNow; + Interlocked.Add(ref _bytesSent, data.Length); + Interlocked.Increment(ref _messagesSent); + UpdateLastActive(); + return true; + } + finally + { + _streamWriteLock.Release(); + } + } + catch (OperationCanceledException ex) + { + DebugConnection($"SendInternalAsync: Write operation canceled for {_config.Host}:{_config.Port}, likely due to timeout or shutdown: {ex.Message}"); + RecordError(ex, "Write timeout in TCP send - stream may be unresponsive"); + await DisconnectClientAsync(DisconnectReason.Timeout, ex); + return false; + } + catch (SocketException socketEx) + { + DebugConnection($"SendInternalAsync: Socket error while sending TCP data to {_config.Host}:{_config.Port}: {socketEx.SocketErrorCode} - {socketEx.Message}"); + RecordError(socketEx, $"Socket error in TCP send: {socketEx.SocketErrorCode} - {socketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset by remote host", SocketError.ConnectionAborted => "Connection aborted", SocketError.HostUnreachable => "Host unreachable", SocketError.NetworkUnreachable => "Network unreachable", SocketError.ConnectionRefused => "Connection refused", _ => "Unknown socket error" }}"); + await DisconnectClientAsync(DisconnectReason.Error, socketEx); + return false; + } + catch (IOException ioEx) when (ioEx.InnerException is SocketException innerSocketEx) + { + DebugConnection($"SendInternalAsync: Socket IO error while sending TCP data to {_config.Host}:{_config.Port}: {innerSocketEx.SocketErrorCode} - {ioEx.Message}"); + RecordError(ioEx, $"Socket IO error in TCP send: {innerSocketEx.SocketErrorCode} - {innerSocketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset by remote host", SocketError.ConnectionAborted => "Connection aborted", _ => "Unknown socket error" }}"); + var reason = innerSocketEx.SocketErrorCode == SocketError.ConnectionReset || innerSocketEx.SocketErrorCode == SocketError.ConnectionAborted ? DisconnectReason.RemoteClosed : DisconnectReason.Error; + await DisconnectClientAsync(reason, ioEx); + return false; + } + catch (ObjectDisposedException ex) + { + DebugConnection($"SendInternalAsync: ObjectDisposedException for {_config.Host}:{_config.Port}, stream was likely disposed during reconnection"); + RecordError(ex, "Stream disposed during TCP send - possible race condition during reconnection attempt"); + if (IsConnected) + { + await DisconnectClientAsync(DisconnectReason.Error, ex).ConfigureAwait(false); + } + return false; + } + catch (Exception ex) + { + DebugConnection($"SendInternalAsync: Exception occurred while sending data to {_config.Host}:{_config.Port}: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Error in TCP send: {ex.GetType().Name} - {ex.Message}"); + await DisconnectClientAsync(DisconnectReason.Error, ex); + return false; + } + } + else + { + try + { + if (_udpClient != null) + { + await _udpClient.SendAsync(data, data.Length).ConfigureAwait(false); + LastDataSent = DateTime.UtcNow; + Interlocked.Add(ref _bytesSent, data.Length); + Interlocked.Increment(ref _messagesSent); + return true; + } + return false; + } + catch (SocketException socketEx) + { + DebugConnection($"SendInternalAsync: Socket error sending UDP data to {_config.Host}:{_config.Port}: {socketEx.SocketErrorCode} - {socketEx.Message}"); + RecordError(socketEx, $"Socket error in UDP send: {socketEx.SocketErrorCode} - {socketEx.SocketErrorCode switch { SocketError.ConnectionReset => "Connection reset", SocketError.HostUnreachable => "Host unreachable", SocketError.NetworkUnreachable => "Network unreachable", _ => "Unknown socket error" }}"); + return false; + } + catch (Exception ex) + { + DebugConnection($"SendInternalAsync: Error sending UDP data to {_config.Host}:{_config.Port}: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Error in UDP send: {ex.GetType().Name} - {ex.Message}"); + return false; } } } - private readonly SemaphoreSlim _writeLock = new(1, 1); - - private async Task WriteToStreamAsync(byte[] dataToSend) + private async Task AutoReconnectAsync(CancellationToken token) { - if (_stream == null || !_stream.CanWrite) - { - return false; - } + IsAutoConnectStarted = true; + int consecutiveFailures = 0; + const int MAX_CONSECUTIVE_FAILURES = 10; + const int MAX_BACKOFF_SECONDS = 300; // 5 minutes max backoff - await _writeLock.WaitAsync().ConfigureAwait(false); - try { - if (_config.Protocol == ProtocolType.TCP) + while (!token.IsCancellationRequested) { - await _stream.WriteAsync(dataToSend, 0, dataToSend.Length).ConfigureAwait(false); + if (!_config.EnableAutoReconnect || + _reason == DisconnectReason.LocalClosed || + !_wasConnected) + { + DebugConnection("Auto-reconnect disabled or not previously connected; stopping auto-reconnect task."); + break; + } + + // Check if the disconnect reason prevents reconnect + if (_reason == DisconnectReason.ClientRequested) + { + // Don't attempt to reconnect if the client explicitly requested disconnection + DebugConnection("Client explicitly requested disconnection; stopping auto-reconnect task."); + _autoReconnectSource?.Cancel(); + break; + } + + if (IsConnected) + { + DebugConnection("Client is already connected; resetting backoff counter."); + consecutiveFailures = 0; + await Task.Delay(1_000, token).ConfigureAwait(false); + continue; + } + + // Try to reconnect with exponential backoff + bool reconnected = await TryReconnectWithRetriesAsync(token).ConfigureAwait(false); + + if (reconnected) + { + consecutiveFailures = 0; + DebugConnection("Reconnection successful; resetting backoff counter."); + } + else + { + consecutiveFailures++; + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) + { + var criticalMsg = $"Too many consecutive reconnection failures ({consecutiveFailures}); stopping auto-reconnect task to prevent resource exhaustion."; + DebugConnection(criticalMsg); + RecordError(null, criticalMsg); + break; + } + + // Calculate exponential backoff: min 1s, max 5 minutes, doubles each attempt + int backoffSeconds = Math.Min( + (int)Math.Pow(2, Math.Min(consecutiveFailures - 1, 8)), + MAX_BACKOFF_SECONDS + ); + + DebugConnection($"Reconnection failed (attempt {consecutiveFailures}); waiting {backoffSeconds}s before next attempt..."); + + try + { + await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } } - else - { - await _udpClient.SendAsync(dataToSend, dataToSend.Length).ConfigureAwait(false); - } - LastActive = DateTime.UtcNow; - LastDataSent = DateTime.UtcNow; + } + catch (OperationCanceledException) + { + DebugConnection("Auto-reconnect task canceled."); + } + catch (Exception ex) + { + DebugConnection($"Auto-reconnect task encountered unexpected error: {ex.GetType().Name} - {ex.Message}"); + RecordError(ex, $"Auto-reconnect task failed: {ex.GetType().Name} - {ex.Message}"); } finally { - try - { - _writeLock.Release(); - } - catch - { - // Do nothing - } + IsAutoConnectStarted = false; + DebugConnection("Auto-reconnect task stopped."); } - return true; } - private async Task AutoReconnectAsync() + private async Task TryReconnectWithRetriesAsync(CancellationToken token) { - if (!_config.EnableAutoReconnect || IsAutoReconnectRunning) + int attempt = 0; + var maxAttempts = _config.MaxReconnectAttempts; + + while (!token.IsCancellationRequested && (maxAttempts == 0 || attempt < maxAttempts)) + { + if (IsConnected) + { + return true; + } + + attempt++; + + try + { + DebugConnection($"Attempting to reconnect... Attempt {attempt}/{(maxAttempts == 0 ? "unlimited" : maxAttempts.ToString())}"); + var connected = await ConnectAsync().ConfigureAwait(false); + + if (connected && IsConnected) + { + _reason = DisconnectReason.Unknown; + + var successMsg = $"Reconnected successfully after {attempt} attempt(s)"; + DebugConnection(successMsg); + + OnGeneralError?.Invoke( + this, + new ErrorEventArgs + { + Message = successMsg + }); + + return true; + } + } + catch (OperationCanceledException) + { + DebugConnection($"Reconnection attempt {attempt} canceled."); + throw; + } + catch (Exception ex) + { + var errorMsg = $"Reconnection attempt {attempt} failed: {ex.GetType().Name} - {ex.Message}"; + DebugConnection(errorMsg); + RecordError(ex, errorMsg); + + OnGeneralError?.Invoke( + this, + new ErrorEventArgs + { + Exception = ex, + Message = errorMsg + }); + } + + // Don't delay after the last failed attempt + if (maxAttempts > 0 && attempt >= maxAttempts) + { + DebugConnection($"Maximum reconnection attempts ({maxAttempts}) reached."); + break; + } + + // Brief delay between retry attempts (exponential backoff is handled at higher level) + Int32 retryDelayMs = Math.Min(500 * attempt, 2000); // Max 2s between individual retries + try + { + await Task.Delay(retryDelayMs, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + + return false; + } + + private void DebugConnection(string debugText) + { + if (!_config.EnableConnectionDebugLogs) { return; } + OnLog?.Invoke(this, $"[NetworkClient] {debugText}"); + } - IsAutoReconnectRunning = true; + private void RecordError(Exception exception, string message, string clientId = null, string nickname = null, bool isSslError = false) + { + var socketEx = exception as SocketException + ?? (exception as IOException)?.InnerException as SocketException; - try + var entry = new SocketErrorEntry { - bool wasConnected = false; - int attempt = 0; - while (_config.EnableAutoReconnect && (_config.MaxReconnectAttempts == 0 || attempt < _config.MaxReconnectAttempts)) + Timestamp = DateTime.UtcNow, + Source = "Client", + ClientId = clientId ?? Nickname ?? "self", + Nickname = nickname ?? Nickname, + SocketErrorCode = socketEx?.SocketErrorCode, + ErrorCode = socketEx != null ? socketEx.SocketErrorCode.ToString() : exception?.GetType().Name, + Message = message, + ExceptionType = exception?.GetType().FullName, + StackTrace = exception?.StackTrace, + Exception = exception, + IsSslError = isSslError + }; + + StatusPage.AddError(entry); + + if (socketEx != null) + { + OnSocketError?.Invoke(this, new ErrorEventArgs { - if (_stopAutoReconnecting) - { - _stopAutoReconnecting = false; - break; - } - - 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; - } - - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, Message = stringBuilder.ToString() }); - } - await Task.Delay(_config.ReconnectDelayInSeconds * 1000).ConfigureAwait(false); - } - } - finally - { - IsAutoReconnectRunning = false; + ClientId = entry.ClientId, + Nickname = entry.Nickname, + Exception = exception, + Message = $"SocketError {socketEx.SocketErrorCode}: {message}" + }); } } - public async Task DisconnectAsync() => await DisconnectClientAsync(DisconnectReason.ClientRequested, forceDisconnection: true).ConfigureAwait(false); + public async Task DisconnectAsync() => await DisconnectClientAsync(DisconnectReason.ClientRequested).ConfigureAwait(false); - public async Task DisconnectClientAsync(DisconnectReason reason = DisconnectReason.LocalClosed, Exception exception = null, bool forceDisconnection = false) + public async Task DisconnectDueTimeoutAsync() => await DisconnectClientAsync(DisconnectReason.Timeout).ConfigureAwait(false); + + private async Task DisconnectClientAsync( + DisconnectReason reason = DisconnectReason.LocalClosed, + Exception exception = null) { - await _connectLock.WaitAsync().ConfigureAwait(false); + DisconnectionTime = DateTime.UtcNow; + IsConnected = false; + _reason = reason; + + if (reason == DisconnectReason.ClientRequested || reason == DisconnectReason.LocalClosed) + { + _autoReconnectSource?.Cancel(); + } + + // Close the actual connection resources + try + { + _connectionCts?.Cancel(); + } + catch + { + // Do nothing + } try { - if (!_isConnected) - { - return; - } - - _isConnected = false; - ConnectionTime = DateTime.MinValue; - _stopAutoReconnecting = forceDisconnection; - - - 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 - { - ClientId = "self", - RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port), - Reason = ConnectionEventArgs.Determine(reason, exception), - Exception = exception - }); - - if (!forceDisconnection && reason != DisconnectReason.Forced && _config.EnableAutoReconnect) - { - _ = Task.Run(() => AutoReconnectAsync()); - } - else - { - OnLog?.Invoke(this, "Auto-reconnect disabled due to forced disconnection."); - } + _connectionCts?.Dispose(); } - finally + catch { - _connectLock.Release(); + // Do nothing } + _connectionCts = null; + + try + { + _stream?.Close(); + _stream?.Dispose(); + _stream = null; + } + catch + { + // Do nothing + } + + try + { + if (_tcpClient != null) + { + try + { + _tcpClient.Client?.Shutdown(System.Net.Sockets.SocketShutdown.Both); + } + catch + { + // Do nothing + } + + _tcpClient.Close(); + _tcpClient.Dispose(); + _tcpClient = null; + } + } + catch + { + // Do nothing + } + + try + { + _udpClient?.Close(); + _udpClient?.Dispose(); + _udpClient = null; + } + catch + { + // Do nothing + } + + OnDisconnected?.Invoke(this, new ConnectionEventArgs + { + ClientId = "self", + RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port), + Reason = ConnectionEventArgs.Determine(reason, exception), + Exception = exception + }); + + StatusPage.StopAutoHtmlReport(); + HealthApi.Stop(); } private async Task SafeTaskCompletion(Task task) @@ -1271,97 +2386,13 @@ namespace EonaCat.Connections return; } - try - { - await task.ConfigureAwait(false); - } - catch - { - // Do nothing - } - } - - public async ValueTask DisposeAsync() - { - if (_disposed) - { - return; - } - - _disposed = true; - try { - 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 - } - - 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; + await task.ConfigureAwait(false); } - finally + catch { - GC.SuppressFinalize(this); + // Do nothing } } @@ -1374,12 +2405,12 @@ namespace EonaCat.Connections } string text = null; - - try + + try { - text = Encoding.UTF8.GetString(data); + text = Encoding.UTF8.GetString(data); } - catch + catch { // Not a valid UTF-8 string } @@ -1389,11 +2420,28 @@ namespace EonaCat.Connections return data; } - if (text.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) + if (text.Contains("[DISCONNECT]", StringComparison.OrdinalIgnoreCase)) { DisconnectClientAsync(DisconnectReason.RemoteClosed).ConfigureAwait(false); hasHeartbeat = true; } + if (text.Contains(Configuration.SSL_ERROR_PREFIX, StringComparison.OrdinalIgnoreCase)) + { + string sslError = null; + var startIdx = text.IndexOf(Configuration.SSL_ERROR_PREFIX, StringComparison.Ordinal) + Configuration.SSL_ERROR_PREFIX.Length; + var endIdx = text.IndexOf(Configuration.SSL_ERROR_SUFFIX, StringComparison.Ordinal); + if (endIdx > startIdx) + { + sslError = text.Substring(startIdx, endIdx - startIdx); + } + RecordError(null, $"Server reported SSL error: {sslError ?? text}", isSslError: true); + OnSslError?.Invoke(this, new ErrorEventArgs + { + Message = $"Server reported SSL error: {sslError ?? text}" + }); + DisconnectClientAsync(DisconnectReason.SSLError).ConfigureAwait(false); + hasHeartbeat = true; + } if (text.Contains(Configuration.PING_VALUE)) { hasHeartbeat = true; @@ -1421,12 +2469,95 @@ namespace EonaCat.Connections } if (hasHeartbeat && !string.IsNullOrEmpty(text)) { - text = text.Replace(Configuration.PING_VALUE, string.Empty).Replace(Configuration.PONG_VALUE, string.Empty).Replace("DISCONNECT", string.Empty); + 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(); + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + try + { + _clientCts?.Cancel(); + _autoReconnectSource?.Cancel(); + _connectionCts?.Cancel(); + } + catch + { + // Do nothing + } + + try { _stream?.Close(); _stream?.Dispose(); _stream = null; } catch { } + try + { + if (_tcpClient != null) + { + try { _tcpClient.Client?.Shutdown(SocketShutdown.Both); } catch { } + _tcpClient.Close(); + _tcpClient.Dispose(); + _tcpClient = null; + } + } + catch { } + try { _udpClient?.Close(); _udpClient?.Dispose(); _udpClient = null; } catch { } + try { _aesEncryption?.Dispose(); _aesEncryption = null; } catch { } + + try { _clientCts?.Dispose(); _clientCts = null; } catch { } + try { _autoReconnectSource?.Dispose(); _autoReconnectSource = null; } catch { } + try { _connectionCts?.Dispose(); _connectionCts = null; } catch { } + + try { _connectLock?.Dispose(); } catch { } + try { _connectionAttemptLock?.Dispose(); } catch { } + try { _streamReadLock?.Dispose(); } catch { } + try { _streamWriteLock?.Dispose(); } catch { } + + HealthApi.Dispose(); + } + + private void ConfigureKeepAlive(Socket socket, int keepAliveTimeSeconds, int keepAliveIntervalSeconds, int keepAliveRetryCount = 10) + { + if (socket == null) + { + return; + } + + try + { + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + +#if NET5_0_OR_GREATER + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, Math.Max(1, keepAliveTimeSeconds)); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, Math.Max(1, keepAliveIntervalSeconds)); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, Math.Max(1, keepAliveRetryCount)); +#else + uint onOff = 1; + uint keepAliveTime = (uint)Math.Max(1, keepAliveTimeSeconds) * 1000; + uint keepAliveInterval = (uint)Math.Max(1, keepAliveIntervalSeconds) * 1000; + + byte[] keepAliveSettings = new byte[12]; + Buffer.BlockCopy(BitConverter.GetBytes(onOff), 0, keepAliveSettings, 0, 4); + Buffer.BlockCopy(BitConverter.GetBytes(keepAliveTime), 0, keepAliveSettings, 4, 4); + Buffer.BlockCopy(BitConverter.GetBytes(keepAliveInterval), 0, keepAliveSettings, 8, 4); + + socket.IOControl(IOControlCode.KeepAliveValues, keepAliveSettings, null); +#endif + } + catch (Exception ex) + { + OnLog?.Invoke(this, $"Failed to configure TCP keep-alive: {ex.Message}"); + } + } } } \ No newline at end of file diff --git a/EonaCat.Connections/NetworkServer.cs b/EonaCat.Connections/NetworkServer.cs index a75732e..17f8cdd 100644 --- a/EonaCat.Connections/NetworkServer.cs +++ b/EonaCat.Connections/NetworkServer.cs @@ -1,7 +1,4 @@ -// 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. - -using EonaCat.Connections.EventArguments; +using EonaCat.Connections.EventArguments; using EonaCat.Connections.Helpers; using EonaCat.Connections.Models; using System.Buffers; @@ -11,26 +8,39 @@ 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 System.Text.RegularExpressions; using ErrorEventArgs = EonaCat.Connections.EventArguments.ErrorEventArgs; using ProtocolType = EonaCat.Connections.Models.ProtocolType; namespace EonaCat.Connections { - public partial class NetworkServer + // 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 partial class NetworkServer : IDisposable { - 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 const int MAX_BUFFER_MEMORY_IN_KILOBYTES = 1024 * 200; + private const int DEFAULT_WAITING_TIMEOUT_IN_SECONDS = 30; private readonly Configuration _config; private readonly Stats _stats; private readonly ConcurrentDictionary _clients; - private TcpListener _tcpListener; - private UdpClient _udpListener; - private CancellationTokenSource _serverCancellation; - private readonly object _statsLock = new object(); - private readonly object _tcpLock = new object(); - private readonly object _udpLock = new object(); + private TcpListener? _tcpListener; + private UdpClient? _udpListener; + + private CancellationTokenSource? _serverCancellation; + + private Task? _tcpTask; + private Task? _udpTask; + private Task? _acceptTcpClientsTask; + private Task? _pingTask; + private Task? _pongTask; + private Task? _cleanupTask; + + public event EventHandler OnLog; + + private readonly SemaphoreSlim _udpLock = new(1, 1); public event EventHandler OnConnected; @@ -46,21 +56,22 @@ namespace EonaCat.Connections public event EventHandler OnGeneralError; + public event EventHandler OnSocketError; + + public SocketStatusPage StatusPage { get; } = new SocketStatusPage(); + 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; - public int ActiveConnections => _clients.Count; + public int ActiveConnections => GetActiveConnectionCount(); + public long DroppedPackets => _stats.DroppedPackets; + public long DroppedConnections => _stats.DroppedConnections; public long TotalConnections => _stats.TotalConnections; public long BytesSent => _stats.BytesSent; public long BytesReceived => _stats.BytesReceived; @@ -73,25 +84,81 @@ namespace EonaCat.Connections 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 volatile int _tcpRunning = 0; + private volatile int _udpRunning = 0; private ArrayPool _arrayPool = ArrayPool.Shared; - private Task _tcpTask; - private Task _udpTask; + private volatile bool _isStarting; + + public ServerStatusPage ServerStatus { get; } + + public HealthApiServer HealthApi { get; } public NetworkServer(Configuration config) { _config = config; _stats = new Stats { StartTime = DateTime.UtcNow }; _clients = new ConcurrentDictionary(); + ServerStatus = new ServerStatusPage(() => _clients, () => GetStats(), _config, StatusPage); + HealthApi = new HealthApiServer(BuildHealthJson, BuildStatusJson); } public Stats GetStats() { - lock (_statsLock) + _stats.ActiveConnections = GetActiveConnectionCount(); + return _stats; + } + + private int GetActiveConnectionCount() + { + if (_clients.IsEmpty) { - _stats.ActiveConnections = _clients.Count; - return _stats; + return 0; + } + + int activeCount = 0; + + foreach (var client in _clients.Values) + { + if (client == null || client.IsClosing) + { + continue; + } + + if (_config.Protocol == ProtocolType.UDP) + { + activeCount++; + continue; + } + + if (IsTcpClientAlive(client)) + { + activeCount++; + } + } + + return activeCount; + } + + private static bool IsTcpClientAlive(Connection client) + { + var socket = client?.TcpClient?.Client; + if (socket == null) + { + return false; + } + + try + { + if (!socket.Connected) + { + return false; + } + + return !(socket.Poll(0, SelectMode.SelectRead) && socket.Available == 0); + } + catch + { + return false; } } @@ -101,215 +168,423 @@ namespace EonaCat.Connections public bool DEBUG_DATA_SEND { get; set; } public bool DEBUG_DATA_RECEIVED { get; set; } + /// + /// Occurs when a client has been idle for longer than the configured timeout period. + /// + /// Subscribers can use this event to perform actions such as disconnecting or notifying + /// idle clients. The event is raised with an instance containing details + /// about the idle client. + public event EventHandler OnIdleTimeout; + + private string BuildHealthJson() + { + var E = HealthApiServer.JsonEscape; + var stats = GetStats(); + return "{" + + "\"status\":\"ok\"," + + "\"type\":\"server\"," + + $"\"listenAddress\":{E(_config.Host + ":" + _config.Port)}," + + $"\"protocol\":{E(_config.Protocol.ToString())}," + + $"\"isStarted\":{(IsStarted ? "true" : "false")}," + + $"\"isSecure\":{(IsSecure ? "true" : "false")}," + + $"\"isEncrypted\":{(IsEncrypted ? "true" : "false")}," + + $"\"activeConnections\":{stats.ActiveConnections}," + + $"\"maxConnections\":{_config.MaxConnections}," + + $"\"uptimeSeconds\":{stats.Uptime.TotalSeconds:F1}," + + $"\"startTimeUtc\":{E(stats.StartTime.ToString("O"))}" + + "}"; + } + + private string BuildStatusJson() + { + var E = HealthApiServer.JsonEscape; + var stats = GetStats(); + var sb = new StringBuilder(); + sb.Append("{"); + sb.Append("\"status\":\"ok\","); + sb.Append("\"type\":\"server\","); + sb.Append($"\"listenAddress\":{E(_config.Host + ":" + _config.Port)},"); + sb.Append($"\"protocol\":{E(_config.Protocol.ToString())},"); + sb.Append($"\"isStarted\":{(IsStarted ? "true" : "false")},"); + sb.Append($"\"isSecure\":{(IsSecure ? "true" : "false")},"); + sb.Append($"\"isEncrypted\":{(IsEncrypted ? "true" : "false")},"); + sb.Append($"\"activeConnections\":{stats.ActiveConnections},"); + sb.Append($"\"maxConnections\":{_config.MaxConnections},"); + sb.Append($"\"totalConnections\":{stats.TotalConnections},"); + sb.Append($"\"droppedConnections\":{stats.DroppedConnections},"); + sb.Append($"\"droppedPackets\":{stats.DroppedPackets},"); + sb.Append($"\"bytesSent\":{stats.BytesSent},"); + sb.Append($"\"bytesReceived\":{stats.BytesReceived},"); + sb.Append($"\"messagesSent\":{stats.MessagesSent},"); + sb.Append($"\"messagesReceived\":{stats.MessagesReceived},"); + sb.Append($"\"messagesPerSecond\":{stats.MessagesPerSecond:F2},"); + sb.Append($"\"uptimeSeconds\":{stats.Uptime.TotalSeconds:F1},"); + sb.Append($"\"startTimeUtc\":{E(stats.StartTime.ToString("O"))},"); + + // Clients array + sb.Append("\"clients\":["); + var clientList = _clients.Values.ToArray(); + for (int i = 0; i < clientList.Length; i++) + { + var c = clientList[i]; + if (i > 0) + { + sb.Append(","); + } + + sb.Append("{"); + sb.Append($"\"id\":{E(c.Id)},"); + sb.Append($"\"nickname\":{E(c.Nickname)},"); + sb.Append($"\"remoteEndPoint\":{E(c.RemoteEndPoint?.ToString())},"); + sb.Append($"\"connectedAtUtc\":{E(c.ConnectedAt.ToString("O"))},"); + sb.Append($"\"connectedTimeSeconds\":{c.ConnectedTime().TotalSeconds:F1},"); + sb.Append($"\"idleTimeSeconds\":{c.IdleTime().TotalSeconds:F1},"); + sb.Append($"\"bytesSent\":{c.BytesSent},"); + sb.Append($"\"bytesReceived\":{c.BytesReceived},"); + sb.Append($"\"lastDataSentUtc\":{(c.LastDataSent == default ? "null" : E(c.LastDataSent.ToString("O")))},"); + sb.Append($"\"lastDataReceivedUtc\":{(c.LastDataReceived == default ? "null" : E(c.LastDataReceived.ToString("O")))},"); + sb.Append($"\"isSecure\":{(c.IsSecure ? "true" : "false")},"); + sb.Append($"\"isEncrypted\":{(c.IsEncrypted ? "true" : "false")}"); + sb.Append("}"); + } + sb.Append("]"); + + // SSL error summary + sb.Append(",\"sslErrors\":{"); + sb.Append($"\"total\":{StatusPage.SslErrorCount},"); + var sslErrors = StatusPage.GetSslErrors(); + sb.Append("\"entries\":["); + for (int i = 0; i < sslErrors.Count; i++) + { + var se = sslErrors[i]; + if (i > 0) + { + sb.Append(","); + } + + sb.Append("{"); + sb.Append($"\"timestamp\":{E(se.Timestamp.ToString("O"))},"); + sb.Append($"\"clientId\":{E(se.ClientId)},"); + sb.Append($"\"nickname\":{E(se.Nickname)},"); + sb.Append($"\"message\":{E(se.Message)},"); + sb.Append($"\"exceptionType\":{E(se.ExceptionType)}"); + sb.Append("}"); + } + sb.Append("]}"); + + sb.Append("}"); + return sb.ToString(); + } + public Task StartAsync() { + if (_isStarting) + { + return Task.CompletedTask; + } + + _isStarting = true; + _serverCancellation?.Dispose(); _serverCancellation = new CancellationTokenSource(); if (_config.Protocol == ProtocolType.TCP) { _tcpTask = Task.Run(() => StartTcpServerAsync(), _serverCancellation.Token); + _cleanupTask = Task.Run(() => CleanupTcpClientsAsync(_serverCancellation.Token), _serverCancellation.Token); } else { - _udpTask = Task.Run(() => StartUdpServerAsync(), _serverCancellation.Token); + _udpTask = Task.Run(() => StartUdpServerAsync(_serverCancellation.Token), _serverCancellation.Token); } if (_config.EnableHeartbeat) { - _pingTask = Task.Run(StartPingLoop, _serverCancellation.Token); - _pongTask = Task.Run(StartPongLoop, _serverCancellation.Token); + _pingTask = Task.Run(() => StartPingLoopAsync(_serverCancellation.Token), _serverCancellation.Token); + _pongTask = Task.Run(() => StartPongLoopAsync(_serverCancellation.Token), _serverCancellation.Token); } + if (_config.EnableAutoHtmlReports) + { + StatusPage.StartAutoHtmlReport( + _config.HtmlReportOutputDirectory, + _config.HtmlReportIntervalSeconds, + "status-server-errors.html"); + } + + if (_config.EnableServerStatusPage) + { + ServerStatus.StartAutoReport( + _config.HtmlReportOutputDirectory, + _config.ServerStatusPageIntervalSeconds, + "status-server.html"); + } + + if (_config.EnableHealthApi) + { + HealthApi.Start(_config.HealthApiPort, _config.HealthApiBindAddress); + } + + _isStarting = false; return Task.CompletedTask; } - private void StartPongLoop() + private async Task StartPongLoopAsync(CancellationToken token) { - _pongCancellation?.Cancel(); - _pongCancellation?.Dispose(); - - _pongCancellation = new CancellationTokenSource(); - var token = _pongCancellation.Token; - - _pongTask = Task.Run(async () => + while (!token.IsCancellationRequested) { - while (!token.IsCancellationRequested) + try { - try + var now = DateTime.UtcNow; + var timeout = _config.HeartbeatIntervalSeconds * 2; + + var pingTimes = _lastPingTimes.ToList(); + var toRemove = new List(); + + foreach (var pings in pingTimes) { - var now = DateTime.UtcNow; - var timeout = _config.HeartbeatIntervalSeconds * 2; + var clientId = pings.Key; + var lastPong = pings.Value; - var pingTimes = _lastPingTimes.ToList(); - var toRemove = new List(); - - foreach (var pings in pingTimes) + if (!_clients.TryGetValue(clientId, out var client)) { - var clientId = pings.Key; - var lastPong = pings.Value; + toRemove.Add(clientId); + continue; + } - if (!_clients.TryGetValue(clientId, out var client)) + if (!client.IsConnected) + { + await DisconnectClientAsync(clientId, DisconnectReason.RemoteClosed); + continue; + } + + var elapsed = (now - lastPong).TotalSeconds; + + if (elapsed > timeout) + { + if (_config.EnablePingPongLogs) { - continue; + OnLog?.Invoke(this, $"Client '{client.Nickname}' no PONG for {elapsed:F1}s, disconnect."); } - if (!client.IsConnected) + OnPongMissed?.Invoke(this, new PingEventArgs { - continue; - } + Id = client.Id, + Nickname = client.Nickname, + ReceivedTime = now, + RemoteEndPoint = client.RemoteEndPoint + }); - var elapsed = (now - lastPong).TotalSeconds; - - if (elapsed > timeout) + if (_config.DisconectOnMissedPong) { - 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); + DebugConnection($"Disconnecting client {client.Nickname} ({client.Id}) due to missed PONG response."); + _ = DisconnectClientAsync(clientId, DisconnectReason.NoPongReceived); } } - foreach (var remove in toRemove) + if (elapsed > _config.HeartbeatIntervalSeconds * 4) { - _lastPingTimes.TryRemove(remove, out _); + toRemove.Add(clientId); } + } - await Task.Delay(TimeSpan.FromSeconds(1), token); - } - catch (TaskCanceledException) + foreach (var remove in toRemove) { - break; - } - catch (Exception exception) - { - OnGeneralError?.Invoke(this, new ErrorEventArgs - { - Exception = exception, - Message = "Pong loop error" - }); + _lastPingTimes.TryRemove(remove, out _); } + + await Task.Delay(TimeSpan.FromSeconds(1), token); } - }, token); + catch (TaskCanceledException) + { + DebugConnection("Pong loop canceled."); + break; + } + catch (Exception exception) + { + DebugConnection($"StartPongLoopAsync: Error checking pong responses for {_config.Host}:{_config.Port}: {exception.Message}"); + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = exception, + Message = "Pong loop error" + }); + } + } } - private void StartPingLoop() + private async Task StartPingLoopAsync(CancellationToken token) { - _pingCancellation?.Cancel(); - _pingCancellation?.Dispose(); - - _pingCancellation = new CancellationTokenSource(); - var token = _pingCancellation.Token; - - _pingTask = Task.Run(async () => + while (!token.IsCancellationRequested) { - while (!token.IsCancellationRequested) + try { - try - { - var clients = _clients.Values.ToList(); - foreach (var client in clients) - { - if (client?.IsConnected != true) - { - continue; - } + var clients = _clients.Values.ToList(); + var pingTasks = new List(clients.Count); - await SendToClientAsync(client.Id, Configuration.PING_VALUE).ConfigureAwait(false); + foreach (var client in clients) + { + if (!client.IsConnected || client.IsClosing) + { + continue; } - await Task.Delay(TimeSpan.FromSeconds(_config.HeartbeatIntervalSeconds), token).ConfigureAwait(false); + pingTasks.Add(SendToClientAsync(client.Id, Configuration.PING_VALUE)); } - catch (TaskCanceledException) + + if (pingTasks.Count > 0) { - break; - } - catch (Exception exception) - { - OnGeneralError?.Invoke(this, new ErrorEventArgs - { - Exception = exception, - Message = "Ping loop error" - }); + await Task.WhenAll(pingTasks).ConfigureAwait(false); } + + await Task.Delay(TimeSpan.FromSeconds(_config.HeartbeatIntervalSeconds), token).ConfigureAwait(false); } - }, token); + catch (TaskCanceledException) + { + DebugConnection("Ping loop canceled."); + break; + } + catch (Exception exception) + { + DebugConnection($"StartPingLoopAsync: Error sending ping to {_config.Host}:{_config.Port}: {exception.Message}"); + RecordError(exception, $"Ping loop error: {exception.Message}"); + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = exception, + Message = "Ping loop error" + }); + } + } } - public async Task StartTcpServerAsync() + private void DebugConnection(string debugText) + { + if (!_config.EnableConnectionDebugLogs) + { + return; + } + OnLog?.Invoke(this, $"[NetworkServer] {debugText}"); + } + + private void RecordError(Exception exception, string message, string clientId = null, string nickname = null, bool isSslError = false) + { + var socketEx = exception as SocketException + ?? (exception as IOException)?.InnerException as SocketException; + + var entry = new SocketErrorEntry + { + Timestamp = DateTime.UtcNow, + Source = "Server", + ClientId = clientId, + Nickname = nickname, + SocketErrorCode = socketEx?.SocketErrorCode, + ErrorCode = socketEx != null ? socketEx.SocketErrorCode.ToString() : exception?.GetType().Name, + Message = message, + ExceptionType = exception?.GetType().FullName, + StackTrace = exception?.StackTrace, + Exception = exception, + IsSslError = isSslError + }; + + StatusPage.AddError(entry); + + if (socketEx != null) + { + OnSocketError?.Invoke(this, new ErrorEventArgs + { + ClientId = clientId, + Nickname = nickname, + Exception = exception, + Message = $"SocketError {socketEx.SocketErrorCode}: {message}" + }); + } + } + + public Task StartTcpServerAsync() { if (Interlocked.CompareExchange(ref _tcpRunning, 1, 0) == 1) { - Console.WriteLine("TCP Server is already running."); - return; + return Task.CompletedTask; } - try + _acceptTcpClientsTask = AcceptClientsAsync(_serverCancellation.Token); + return Task.CompletedTask; + } + + private async Task AcceptClientsAsync(CancellationToken token) + { + if (_tcpListener == null || !_tcpListener.Server.IsBound) { _tcpListener = new TcpListener(IPAddress.Parse(_config.Host), _config.Port); + _tcpListener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _tcpListener.Start(); - Console.WriteLine($"EonaCat TCP Server started on {_config.Host}:{_config.Port}"); - - while (!_serverCancellation.Token.IsCancellationRequested) - { - TcpClient tcpClient = null; - try - { - tcpClient = await _tcpListener.AcceptTcpClientAsync().ConfigureAwait(false); - _ = Task.Run(() => HandleTcpClientAsync(tcpClient), _serverCancellation.Token); - } - catch (ObjectDisposedException) - { - break; - } - catch (InvalidOperationException exception) when (exception.Message.Contains("Not listening")) - { - break; - } - catch (Exception exception) - { - OnGeneralError?.Invoke(this, new ErrorEventArgs - { - Exception = exception, - Message = "Error accepting TCP client" - }); - } - } + Console.WriteLine($"EonaCat.Connections TCP Server started on {_config.Host}:{_config.Port}"); } - finally + + while (!token.IsCancellationRequested) { - StopTcpServer(); + try + { + if (token.IsCancellationRequested) + { + DebugConnection("Cancellation requested, stopping accept loop."); + break; + } + + if (_tcpListener == null) + { + DebugConnection("TCP listener is null, stopping accept loop."); + break; + } + + TcpClient tcpClient = await _tcpListener.AcceptTcpClientAsync().ConfigureAwait(false); + + if (_config.MaxConnections > 0 && GetActiveConnectionCount() >= _config.MaxConnections) + { + tcpClient.Close(); + tcpClient.Dispose(); + _stats.IncrementDroppedConnections(); + OnLog?.Invoke(this, $"Incoming TCP client ignored: max connections ({_config.MaxConnections}) reached."); + continue; + } + + _ = Task.Run(() => HandleTcpClientAsync(tcpClient), token); + } + catch (ObjectDisposedException) + { + DebugConnection("TCP listener disposed, stopping accept loop."); + break; + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted || ex.SocketErrorCode == SocketError.OperationAborted) + { + DebugConnection($"TCP listener interrupted, stopping accept loop: {ex.Message}"); + RecordError(ex, $"TCP listener interrupted: {ex.SocketErrorCode}"); + break; + } + catch (SocketException ex) + { + DebugConnection($"Socket error in accept loop: {ex.Message}"); + RecordError(ex, $"Socket error in accept loop: {ex.SocketErrorCode}"); + OnLog?.Invoke(this, $"Socket error in accept loop: {ex.Message}"); + await Task.Delay(100, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + DebugConnection("Accept loop canceled."); + break; + } + catch (Exception ex) + { + DebugConnection($"Unexpected error in accept loop: {ex.Message}"); + RecordError(ex, $"Unexpected error in accept loop: {ex.Message}"); + OnLog?.Invoke(this, $"Unexpected error in accept loop: {ex.Message}"); + await Task.Delay(100, token).ConfigureAwait(false); + } } } - private void StopTcpServer() + public async Task StopTcpServerAsync() { - lock (_tcpLock) - { - _tcpListener?.Stop(); - _tcpListener = null; - _udpTask = null; - _tcpTask = null; - } - Interlocked.Exchange(ref _tcpRunning, 0); + await StopAsync().ConfigureAwait(false); } public Dictionary GetClients() @@ -317,52 +592,78 @@ namespace EonaCat.Connections return _clients.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); } - public async Task StartUdpServerAsync() + public async Task StartUdpServerAsync(CancellationToken token) { if (Interlocked.CompareExchange(ref _udpRunning, 1, 0) == 1) { - Console.WriteLine("EonaCat UDP Server is already running."); + Console.WriteLine("EonaCat.Connections UDP Server is already running."); return; } - _ = Task.Run(() => CleanupUdpClientsAsync(), _serverCancellation.Token).ConfigureAwait(false); - try { - lock (_udpLock) + await _udpLock.WaitAsync(token).ConfigureAwait(false); + try { _udpListener = new UdpClient(_config.Port); } + finally + { + _udpLock.Release(); + } - Console.WriteLine($"EonaCat UDP Server started on {_config.Host}:{_config.Port}"); + Console.WriteLine($"EonaCat.Connections UDP Server started on {_config.Host}:{_config.Port}"); - while (!_serverCancellation.Token.IsCancellationRequested) + _ = Task.Run(async () => await CleanupUdpClientsAsync(token), token).ConfigureAwait(false); + + while (!token.IsCancellationRequested) { try { UdpReceiveResult result; - lock (_udpLock) + await _udpLock.WaitAsync(token).ConfigureAwait(false); + try { if (_udpListener == null) { break; } + + result = await _udpListener!.ReceiveAsync().ConfigureAwait(false); + } + finally + { + _udpLock.Release(); } - result = await _udpListener!.ReceiveAsync().ConfigureAwait(false); - _ = Task.Run(() => HandleUdpDataAsync(result), _serverCancellation.Token); + _ = Task.Run(() => HandleUdpDataAsync(result), token); } - catch (ObjectDisposedException) + catch (ObjectDisposedException) { + DebugConnection("UDP listener disposed, stopping receive loop."); break; } - catch (SocketException exception) when (exception.SocketErrorCode == SocketError.Interrupted) + catch (SocketException exception) when (exception.SocketErrorCode == SocketError.Interrupted) { + DebugConnection("UDP listener interrupted, stopping receive loop."); + RecordError(exception, $"UDP listener interrupted: {exception.SocketErrorCode}"); break; } + catch (SocketException exception) + { + DebugConnection($"Socket error receiving UDP data: {exception.SocketErrorCode} - {exception.Message}"); + RecordError(exception, $"Socket error receiving UDP data: {exception.SocketErrorCode}"); + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = exception, + Message = $"UDP receive socket error: {exception.SocketErrorCode}" + }); + } catch (Exception exception) { + DebugConnection($"Error receiving UDP data: {exception.Message}"); + RecordError(exception, $"Error receiving UDP data: {exception.Message}"); OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = exception, @@ -373,25 +674,96 @@ namespace EonaCat.Connections } finally { - StopUdpServer(); + DebugConnection("UDP server stopping, closing listener and disconnecting clients."); + await StopUdpServerAsync(); } } - private void StopUdpServer() + private async Task StopUdpServerAsync() { - lock (_udpLock) + await _udpLock.WaitAsync().ConfigureAwait(false); + try { _udpListener?.Close(); _udpListener?.Dispose(); _udpListener = null; } + finally + { + _udpLock.Release(); + } Interlocked.Exchange(ref _udpRunning, 0); } + private void ConfigureKeepAlive(Socket socket, int keepAliveTimeSeconds, int keepAliveIntervalSeconds, int keepAliveRetryCount = 10) + { + if (socket == null) + { + return; + } + + try + { + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + +#if NET5_0_OR_GREATER + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, Math.Max(1, keepAliveTimeSeconds)); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, Math.Max(1, keepAliveIntervalSeconds)); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, Math.Max(1, keepAliveRetryCount)); +#else + uint onOff = 1; + uint keepAliveTime = (uint)Math.Max(1, keepAliveTimeSeconds) * 1000; + uint keepAliveInterval = (uint)Math.Max(1, keepAliveIntervalSeconds) * 1000; + + byte[] keepAliveSettings = new byte[12]; + Buffer.BlockCopy(BitConverter.GetBytes(onOff), 0, keepAliveSettings, 0, 4); + Buffer.BlockCopy(BitConverter.GetBytes(keepAliveTime), 0, keepAliveSettings, 4, 4); + Buffer.BlockCopy(BitConverter.GetBytes(keepAliveInterval), 0, keepAliveSettings, 8, 4); + + socket.IOControl(IOControlCode.KeepAliveValues, keepAliveSettings, null); +#endif + } + catch (Exception ex) + { + OnLog?.Invoke(this, $"Failed to configure TCP keep-alive: {ex.Message}"); + } + } + + private void ConfigureSocketOptions(Socket socket) + { + if (socket == null) + { + return; + } + + try + { + // Set socket-level timeouts as a fallback + socket.ReceiveTimeout = (int)(_config.ReadTimeoutSeconds * 1000); + socket.SendTimeout = (int)(_config.WriteTimeoutSeconds * 1000); + + // Configure linger to prevent hanging connections + socket.LingerState = new LingerOption(_config.EnableRST, 0); + + // Disable delay for connection close + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.DontLinger, !_config.EnableRST); + + // Set buffer sizes to match configuration + socket.ReceiveBufferSize = _config.BufferSize; + socket.SendBufferSize = _config.BufferSize; + + DebugConnection($"Socket options configured: ReceiveTimeout={_config.ReadTimeoutSeconds}s, SendTimeout={_config.WriteTimeoutSeconds}s, Linger={_config.EnableRST}"); + } + catch (Exception ex) + { + DebugConnection($"Error configuring socket options: {ex.Message}"); + } + } + private async Task HandleTcpClientAsync(TcpClient tcpClient) { var clientId = Guid.NewGuid().ToString(); - var remoteEndPoint = (IPEndPoint)tcpClient.Client.RemoteEndPoint; + var remoteEndPoint = tcpClient.Client.RemoteEndPoint as IPEndPoint ?? new IPEndPoint(IPAddress.None, 0); var client = new Connection { Id = clientId, @@ -402,48 +774,193 @@ namespace EonaCat.Connections CancellationToken = new CancellationTokenSource() }; + UpdateLastActive(client); + try { tcpClient.NoDelay = !_config.EnableNagle; + + // Configure socket-level timeouts and options + ConfigureSocketOptions(tcpClient.Client); + + if (_config.EnableKeepAlive) + { + ConfigureKeepAlive(tcpClient.Client, _config.KeepAliveTimeSeconds, _config.KeepAliveIntervalSeconds, _config.KeepAliveRetryCount); + } + Stream stream = tcpClient.GetStream(); + + // Set NetworkStream timeouts + if (stream is NetworkStream networkStream) + { + networkStream.ReadTimeout = (int)(_config.ReadTimeoutSeconds * 1000); + networkStream.WriteTimeout = (int)(_config.WriteTimeoutSeconds * 1000); + DebugConnection($"Set stream timeouts: Read={_config.ReadTimeoutSeconds}s, Write={_config.WriteTimeoutSeconds}s for {remoteEndPoint}"); + } if (_config.UseSsl) { bool sslAuthenticated = false; - + var sslDiagnostics = _config.EnableSslDiagnostics ? new Helpers.SslHandshakeDiagnostics() : null; + for (int attempt = 1; attempt <= _config.SSLMaxRetries || _config.SSLMaxRetries == 0; attempt++) { - SslStream sslStream = null; - + SslStream? sslStream = null; + try { + sslDiagnostics?.StartHandshake(remoteEndPoint.ToString(), _config.MutuallyAuthenticate); + sslStream = new SslStream(stream, leaveInnerStreamOpen: true, _config.GetRemoteCertificateValidationCallback()); - await sslStream.AuthenticateAsServerAsync(_config.Certificate, _config.MutuallyAuthenticate, SslProtocols.Tls12 | SslProtocols.Tls13, _config.CheckCertificateRevocation ).ConfigureAwait(false); - + +#if NET8_0_OR_GREATER + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = _config.Certificate, + ClientCertificateRequired = _config.MutuallyAuthenticate, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + CertificateRevocationCheckMode = _config.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, + AllowRenegotiation = _config.AllowTlsRenegotiation + }; + DebugConnection($"Starting SSL authentication for {remoteEndPoint}, attempt {attempt}."); + + sslDiagnostics?.StartStage("ServerAuth"); + await sslStream.AuthenticateAsServerAsync(serverOptions, CancellationToken.None).ConfigureAwait(false); + sslDiagnostics?.EndStage("ServerAuth"); + + DebugConnection($"SSL authentication successful for {remoteEndPoint} on attempt {attempt}."); +#else + sslDiagnostics?.StartStage("ServerAuth"); + await sslStream.AuthenticateAsServerAsync(_config.Certificate, _config.MutuallyAuthenticate, SslProtocols.Tls12 | SslProtocols.Tls13, _config.CheckCertificateRevocation).ConfigureAwait(false); + sslDiagnostics?.EndStage("ServerAuth"); +#endif + stream = sslStream; client.IsSecure = true; sslAuthenticated = true; + + sslDiagnostics?.RecordSuccess(sslStream); + if (sslDiagnostics != null) + { + DebugConnection($"[SSL Metrics] {sslDiagnostics}"); + } break; } catch (IOException ioException) when (ioException.Message.Contains("0 bytes from the transport stream") || ioException.Message.Contains("Unexpected EOF")) { + var isRecoverable = Helpers.SslHandshakeDiagnostics.IsRecoverableFailure(ioException); + sslDiagnostics?.RecordFailure(ioException, isRecoverable); + + RecordError(ioException, "SSL handshake failed due to EOF", clientId, client.Nickname, isSslError: true); + OnSslError?.Invoke(this, new ErrorEventArgs + { + ClientId = clientId, + Nickname = client.Nickname, + Exception = ioException, + Message = "SSL handshake failed due to EOF" + }); + DebugConnection($"SSL handshake EOF from {remoteEndPoint} on attempt {attempt}: {ioException.Message}"); OnLog?.Invoke(this, $"SSL handshake EOF from {remoteEndPoint}. Attempt {attempt}"); } catch (AuthenticationException authException) { + var isRecoverable = Helpers.SslHandshakeDiagnostics.IsRecoverableFailure(authException); + sslDiagnostics?.RecordFailure(authException, isRecoverable); + + RecordError(authException, "SSL authentication failed", clientId, client.Nickname, isSslError: true); + OnSslError?.Invoke(this, new ErrorEventArgs + { + ClientId = clientId, + Nickname = client.Nickname, + Exception = authException, + Message = "SSL authentication failed" + }); + DebugConnection($"SSL authentication failed for {remoteEndPoint} on attempt {attempt}: {authException.Message}"); OnLog?.Invoke(this, $"SSL Authentication failed for {remoteEndPoint}: {authException.Message}"); + + if (!isRecoverable) + { + try + { + var notification = Encoding.UTF8.GetBytes($"{Configuration.SSL_ERROR_PREFIX}SSL authentication failed: {authException.Message}{Configuration.SSL_ERROR_SUFFIX}"); + var rawStream = tcpClient.GetStream(); + await rawStream.WriteAsync(notification, 0, notification.Length).ConfigureAwait(false); + await rawStream.FlushAsync().ConfigureAwait(false); + } + catch + { + // Cannot notify client, likely already disconnected + } + tcpClient?.Client?.Disconnect(false); + break; + } + } + catch (Exception ex) + { + var isRecoverable = Helpers.SslHandshakeDiagnostics.IsRecoverableFailure(ex); + sslDiagnostics?.RecordFailure(ex, isRecoverable); + + RecordError(ex, $"SSL error: {ex.GetType().Name}", clientId, client.Nickname, isSslError: true); + DebugConnection($"SSL error for {remoteEndPoint} on attempt {attempt}: {ex.Message}"); + + if (!isRecoverable) + { + break; + } } finally { +#if NET8_0_OR_GREATER if (!sslAuthenticated) { + DebugConnection($"Disposing SSL stream for {remoteEndPoint} after failed authentication attempt {attempt}."); + + try + { + await (sslStream?.DisposeAsync() ?? ValueTask.CompletedTask); + } + catch + { + // Do nothing + } + } +#else + if (!sslAuthenticated) + { + DebugConnection($"Disposing SSL stream for {remoteEndPoint} after failed authentication attempt {attempt}."); sslStream?.Dispose(); } +#endif + } + + if (!sslAuthenticated && (attempt < _config.SSLMaxRetries || _config.SSLMaxRetries == 0)) + { + int delaySeconds = _config.GetSslRetryDelaySeconds(attempt); + DebugConnection($"SSL retry in {delaySeconds}s (attempt {attempt})"); + sslDiagnostics?.StartStage("RetryDelay"); + await Task.Delay(delaySeconds * 1000).ConfigureAwait(false); + sslDiagnostics?.EndStage("RetryDelay"); } - await Task.Delay(_config.SSLRetryDelayInSeconds * 1000); } + if (!sslAuthenticated) { + DebugConnection($"SSL authentication failed for {remoteEndPoint} after {_config.SSLMaxRetries} attempts, disconnecting client."); + if (sslDiagnostics != null) + { + DebugConnection($"[SSL Final Metrics] {sslDiagnostics}"); + } + try + { + var notification = Encoding.UTF8.GetBytes($"{Configuration.SSL_ERROR_PREFIX}SSL authentication failed after {_config.SSLMaxRetries} attempts{Configuration.SSL_ERROR_SUFFIX}"); + var rawStream = tcpClient.GetStream(); + await rawStream.WriteAsync(notification, 0, notification.Length).ConfigureAwait(false); + await rawStream.FlushAsync().ConfigureAwait(false); + } + catch + { + // Cannot notify client, likely already disconnected + } await DisconnectClientAsync(clientId, DisconnectReason.SSLError).ConfigureAwait(false); return; } @@ -451,6 +968,7 @@ namespace EonaCat.Connections if (_config.EnableKeepAlive) { + DebugConnection($"Enabling TCP keep-alive for client {client.Nickname} ({client.Id}) with keep-alive time {_config.KeepAliveTimeSeconds}s and interval {_config.KeepAliveIntervalSeconds}s."); tcpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); } @@ -473,20 +991,22 @@ namespace EonaCat.Connections Exception = exception, Message = "AES setup failed" }); + client.Stream = stream; + try { await client.DisposeAsync(); } catch { } return; } } client.Stream = stream; - client.LastActive = DateTime.UtcNow; + UpdateLastActive(client); client.LastDataReceived = DateTime.UtcNow; client.LastDataSent = DateTime.UtcNow; _clients[clientId] = client; - lock (_statsLock) - { - _stats.TotalConnections++; - } + client.ReceiveDataTask = Task.Run(() => HandleClientCommunicationAsync(client), client.CancellationToken.Token); + _stats.IncrementTotalConnections(); + + DebugConnection($"Client connected: {client.Nickname} ({client.Id}) from {remoteEndPoint}. Total connections: {ActiveConnections}"); OnConnected?.Invoke(this, new ConnectionEventArgs { @@ -494,44 +1014,123 @@ namespace EonaCat.Connections RemoteEndPoint = remoteEndPoint, Nickname = client.Nickname }); - - await HandleClientCommunicationAsync(client).ConfigureAwait(false); + } + catch (SocketException socketEx) + { + DebugConnection($"Socket error handling TCP client {remoteEndPoint}: {socketEx.SocketErrorCode} - {socketEx.Message}"); + RecordError(socketEx, $"Socket error handling TCP client: {socketEx.SocketErrorCode}", clientId, client.Nickname); + if (!_clients.ContainsKey(clientId)) + { + try { await client.DisposeAsync(); } catch { } + } + else + { + await DisconnectClientAsync(clientId, DisconnectReason.Error, socketEx).ConfigureAwait(false); + } } catch (Exception exception) { - await DisconnectClientAsync(clientId, DisconnectReason.Error, exception).ConfigureAwait(false); - } - finally - { - if (_clients.ContainsKey(clientId)) + DebugConnection($"Error handling TCP client {remoteEndPoint}: {exception.Message}"); + RecordError(exception, $"Error handling TCP client: {exception.Message}", clientId, client.Nickname); + if (!_clients.ContainsKey(clientId)) { - await DisconnectClientAsync(clientId, DisconnectReason.Unknown).ConfigureAwait(false); + try { await client.DisposeAsync(); } catch { } + } + else + { + await DisconnectClientAsync(clientId, DisconnectReason.Error, exception).ConfigureAwait(false); } } } - private async Task CleanupUdpClientsAsync() + private static void UpdateLastActive(Connection client) { - _ = Task.Run(async () => + if (client == null) { - while (!_serverCancellation.IsCancellationRequested) + return; + } + + var now = DateTime.UtcNow; + client.LastActive = now; + client.IsIdleTimeoutTriggered = false; + client.LastIdleLogUtc = DateTime.MinValue; + } + + private async Task CleanupTcpClientsAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try { foreach (var kvp in _clients) { - if ((DateTime.UtcNow - kvp.Value.LastActive).TotalMinutes > _config.ClientTimeoutInMinutes) + var client = kvp.Value; + + if (client.IsClosing) { - await DisconnectClientAsync(kvp.Key, DisconnectReason.Timeout); + continue; + } + + if (!client.IsConnected) + { + DebugConnection($"TCP client {client.Nickname} ({kvp.Key}) detected as disconnected during cleanup, removing."); + await DisconnectClientAsync(kvp.Key, DisconnectReason.RemoteClosed); + continue; + } + + if (_config.IdleTimeoutSeconds > 0 && (DateTime.UtcNow - client.LastActive).TotalSeconds > _config.IdleTimeoutSeconds) + { + DebugConnection($"TCP client {client.Nickname} ({kvp.Key}) idle for more than {_config.IdleTimeoutSeconds} seconds"); + + if (!client.IsIdleTimeoutTriggered) + { + client.IsIdleTimeoutTriggered = true; + OnIdleTimeout?.Invoke(this, new IdleEventArgs(_config.IdleTimeoutSeconds, client, "The client has been idle for too long.")); + } + + await DisconnectClientAsync(kvp.Key, DisconnectReason.Timeout).ConfigureAwait(false); } } - await Task.Delay(TimeSpan.FromMinutes(1), _serverCancellation.Token); } - }); + catch (TaskCanceledException) + { + DebugConnection("CleanupTcpClientsAsync canceled."); + break; + } + catch (Exception exception) + { + DebugConnection($"CleanupTcpClientsAsync error: {exception.Message}"); + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = exception, + Message = "TCP client cleanup error" + }); + } + + await Task.Delay(TimeSpan.FromSeconds(30), token); + } + } + + private async Task CleanupUdpClientsAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + foreach (var kvp in _clients) + { + if (_config.IdleTimeoutSeconds > 0 && (DateTime.UtcNow - kvp.Value.LastActive).TotalSeconds > _config.IdleTimeoutSeconds) + { + DebugConnection($"UDP client {kvp.Value.Nickname} ({kvp.Key}) idle for more than {_config.IdleTimeoutSeconds} seconds, disconnecting."); + await DisconnectClientAsync(kvp.Key, DisconnectReason.Timeout); + } + } + await Task.Delay(TimeSpan.FromMilliseconds(500), token); + } } private async Task HandleUdpDataAsync(UdpReceiveResult result) { var clientKey = result.RemoteEndPoint.ToString(); - + if (!_clients.TryGetValue(clientKey, out var client)) { client = new Connection @@ -541,11 +1140,8 @@ namespace EonaCat.Connections ConnectedAt = DateTime.UtcNow }; _clients[clientKey] = client; - - lock (_statsLock) - { - _stats.TotalConnections++; - } + + _stats.IncrementTotalConnections(); OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientKey, RemoteEndPoint = result.RemoteEndPoint }); } await ProcessReceivedDataAsync(client, result.Buffer); @@ -555,25 +1151,285 @@ namespace EonaCat.Connections public void SetSeparator(byte[] separator) => _config.Delimiter = separator; - private static int IndexOfDelimiter(List buffer, byte[] delimiter) + private async Task HandleClientCommunicationAsync(Connection client) { - if (buffer == null || delimiter == null || buffer.Count < delimiter.Length) + if (client.Stream == null) { - return -1; + DebugConnection("Client stream is null at start of communication loop."); + throw new InvalidOperationException("Client stream is null."); } - for (int i = 0; i <= buffer.Count - delimiter.Length; i++) + var stream = client.Stream; + var token = client.CancellationToken.Token; + + byte[] readBuffer = ArrayPool.Shared.Rent(_config.BufferSize); + + using var messageStream = new MemoryStream(1024 * 64); + + try + { + while (!token.IsCancellationRequested && client.IsConnected && stream != null) + { + int bytesRead = 0; + + try + { + bytesRead = await stream.ReadAsync(readBuffer, 0, _config.BufferSize, token); + } + catch (IOException ioEx) when (ioEx.InnerException is SocketException socketEx) + { + RecordError(ioEx, $"Socket IO error during read: {socketEx.SocketErrorCode}", client.Id, client.Nickname); + + // Handle socket timeouts and connection resets + if (socketEx.SocketErrorCode == SocketError.TimedOut) + { + DebugConnection($"Read timeout for client {client.Nickname} ({client.Id}), disconnecting."); + await DisconnectClientAsync(client.Id, DisconnectReason.Timeout); + return; + } + else if (socketEx.SocketErrorCode == SocketError.ConnectionReset || + socketEx.SocketErrorCode == SocketError.ConnectionAborted) + { + DebugConnection($"Connection reset/aborted for client {client.Nickname} ({client.Id})."); + await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed); + return; + } + throw; + } + catch (SocketException socketEx) + { + RecordError(socketEx, $"Socket error during read: {socketEx.SocketErrorCode}", client.Id, client.Nickname); + DebugConnection($"Socket error during read for client {client.Nickname} ({client.Id}): {socketEx.SocketErrorCode}"); + + if (socketEx.SocketErrorCode == SocketError.TimedOut) + { + await DisconnectClientAsync(client.Id, DisconnectReason.Timeout, socketEx); + return; + } + else if (socketEx.SocketErrorCode == SocketError.ConnectionReset || + socketEx.SocketErrorCode == SocketError.ConnectionAborted) + { + await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed, socketEx); + return; + } + throw; + } + + if (bytesRead == 0) + { + DebugConnection($"Client {client.Nickname} disconnected (0 bytes read)."); + await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed); + return; + } + + UpdateLastActive(client); + await messageStream.WriteAsync(readBuffer, 0, bytesRead, token); + + if (_config.MessageFraming == FramingMode.LengthPrefixed) + { + while (TryExtractLengthPrefixedMessage(messageStream, out byte[]? message)) + { + if (client.IsEncrypted && client.AesEncryption != null) + { + message = await AesKeyExchange.DecryptDataAsync(message, message.Length, client.AesEncryption); + } + + await ProcessReceivedDataAsync(client, message); + } + } + else if (_config.MessageFraming == FramingMode.Delimiter) + { + while (TryExtractDelimitedMessage(messageStream, out byte[]? message)) + { + if (client.IsEncrypted && client.AesEncryption != null) + { + message = await AesKeyExchange.DecryptDataAsync(message, message.Length, client.AesEncryption); + } + + await ProcessReceivedDataAsync(client, message); + } + } + else if (_config.MessageFraming == FramingMode.None) + { + var data = messageStream.ToArray(); + messageStream.SetLength(0); + + if (client.IsEncrypted && client.AesEncryption != null) + { + data = await AesKeyExchange.DecryptDataAsync(data, data.Length, client.AesEncryption); + } + + await ProcessReceivedDataAsync(client, data); + } + } + } + catch (OperationCanceledException) + { + DebugConnection($"Communication loop canceled for client {client.Nickname} ({client.Id})."); + // normal shutdown + } + catch (SocketException socketEx) + { + DebugConnection($"Socket error in communication loop for client {client.Nickname} ({client.Id}): {socketEx.SocketErrorCode} - {socketEx.Message}, Disconnecting"); + RecordError(socketEx, $"Socket error in communication loop: {socketEx.SocketErrorCode}", client.Id, client.Nickname); + var reason = socketEx.SocketErrorCode == SocketError.ConnectionReset + || socketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(client.Id, reason, socketEx); + } + catch (ObjectDisposedException) + { + // Stream was disposed during disconnect + if (client.IsConnected) + { + DebugConnection($"Stream disposed for client {client.Nickname} ({client.Id}) during communication loop, disconnecting client."); + await DisconnectClientAsync(client.Id, DisconnectReason.Error); + } + } + catch (IOException ioEx) when (ioEx.InnerException is SocketException innerSocketEx) + { + DebugConnection($"Socket IO error in communication loop for client {client.Nickname} ({client.Id}): {innerSocketEx.SocketErrorCode} - {ioEx.Message}, Disconnecting"); + RecordError(ioEx, $"Socket IO error in communication loop: {innerSocketEx.SocketErrorCode}", client.Id, client.Nickname); + var reason = innerSocketEx.SocketErrorCode == SocketError.ConnectionReset + || innerSocketEx.SocketErrorCode == SocketError.ConnectionAborted + ? DisconnectReason.RemoteClosed + : DisconnectReason.Error; + await DisconnectClientAsync(client.Id, reason, ioEx); + } + catch (IOException ioEx) + { + DebugConnection($"IO error in communication loop for client {client.Nickname} ({client.Id}): {ioEx.Message}, Disconnecting"); + RecordError(ioEx, $"IO error in communication loop: {ioEx.Message}", client.Id, client.Nickname); + await DisconnectClientAsync(client.Id, DisconnectReason.Error, ioEx); + } + catch (Exception ex) + { + DebugConnection($"Error in communication loop for client {client.Nickname} ({client.Id}): {ex.Message}, Disconnecting"); + RecordError(ex, $"Error in communication loop: {ex.Message}", client.Id, client.Nickname); + await DisconnectClientAsync(client.Id, DisconnectReason.Error, ex); + } + finally + { + ArrayPool.Shared.Return(readBuffer); + } + } + + private bool TryExtractLengthPrefixedMessage(MemoryStream stream, out byte[]? message) + { + message = null; + + var buffer = stream.GetBuffer(); + int length = (int)stream.Length; + + int prefixSize = _config.LengthPrefixedLength; + + if (length < prefixSize) + { + return false; + } + + long messageLengthValue = ParseLengthPrefix(buffer, prefixSize, _config.UseBigEndian); + + if (messageLengthValue <= 0 || messageLengthValue > int.MaxValue) + { + throw new InvalidOperationException("Invalid message length."); + } + + int messageLength = (int)messageLengthValue; + + if (length < prefixSize + messageLength) + { + return false; + } + + message = new byte[messageLength]; + Buffer.BlockCopy(buffer, prefixSize, message, 0, messageLength); + + // Slide remaining bytes + int remaining = length - (prefixSize + messageLength); + Buffer.BlockCopy( + buffer, + prefixSize + messageLength, + buffer, + 0, + remaining); + + stream.SetLength(remaining); + + return true; + } + + private static long ParseLengthPrefix(byte[] buffer, int length, bool useBigEndian) + { + long value = 0; + + if (useBigEndian) + { + for (int i = 0; i < length; i++) + { + value = (value << 8) | buffer[i]; + } + } + else + { + for (int i = 0; i < length; i++) + { + value |= (long)buffer[i] << (8 * i); + } + } + + return value; + } + + private bool TryExtractDelimitedMessage(MemoryStream stream, out byte[]? message) + { + message = null; + var delimiter = _config.Delimiter; + + if (delimiter == null || delimiter.Length == 0) + { + return false; + } + + var buffer = stream.GetBuffer(); + int length = (int)stream.Length; + + if (length < delimiter.Length) + { + return false; + } + + int index = IndexOfDelimiter(buffer, length, delimiter); + + if (index < 0) + { + return false; + } + + message = new byte[index]; + Buffer.BlockCopy(buffer, 0, message, 0, index); + + int remaining = length - index - delimiter.Length; + Buffer.BlockCopy(buffer, index + delimiter.Length, buffer, 0, remaining); + stream.SetLength(remaining); + + return true; + } + + private static int IndexOfDelimiter(byte[] buffer, int length, byte[] delimiter) + { + for (int i = 0; i <= length - delimiter.Length; i++) { bool match = true; for (int j = 0; j < delimiter.Length; j++) { - if (buffer[i + j] != delimiter[j]) + if (buffer[i + j] != delimiter[j]) { - match = false; + match = false; break; } } - if (match) { return i; @@ -582,144 +1438,39 @@ namespace EonaCat.Connections return -1; } - private async Task HandleClientCommunicationAsync(Connection client) + private void ProcessPingPong(Connection client, ref string message) { - if (client.Stream == null) + if (!_config.EnableHeartbeat || string.IsNullOrEmpty(message)) { - throw new InvalidOperationException("Client stream is null or disposed."); + return; } - byte[] readBuffer = ArrayPool.Shared.Rent(_config.BufferSize); - var aggregate = new List(_config.BufferSize * 2); - var stream = client.Stream; - - try + if (message.Contains(Configuration.PING_VALUE)) { - while (!client.CancellationToken.Token.IsCancellationRequested && client.TcpClient.Connected) + if (_config.EnablePingPongLogs) { - int bytesRead; - - try - { - 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 (client.IsEncrypted && client.AesEncryption != null) - { - try - { - await AesKeyExchange.DecryptDataAsync(message, message.Length, client.AesEncryption).ConfigureAwait(false); - } - catch (Exception exception) - { - await DisconnectClientAsync(client.Id, DisconnectReason.Error, exception).ConfigureAwait(false); - return; - } - } - await ProcessReceivedDataAsync(client, message).ConfigureAwait(false); - } - if (aggregate.Count > 0 && DEBUG_DATA_RECEIVED) - { - OnLog?.Invoke(this, $"[DEBUG] {aggregate.Count} bytes remain unparsed after read."); - } + 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); } - catch (OperationCanceledException) + + if (message.Contains(Configuration.PONG_VALUE)) { - // Do nothing, cancellation requested - } - catch (Exception exception) - { - await DisconnectClientAsync(client.Id, DisconnectReason.Error, exception).ConfigureAwait(false); - } - finally - { - ArrayPool.Shared.Return(readBuffer, clearArray: true); + 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); } } @@ -733,39 +1484,50 @@ namespace EonaCat.Connections try { var now = DateTime.UtcNow; - LastDataReceived = now; client.LastDataReceived = now; - client.LastActive = now; + UpdateLastActive(client); client.AddBytesReceived(data.Length); _lastPingTimes[client.Id] = now; - lock (_statsLock) - { - _stats.BytesReceived += data.Length; - _stats.MessagesReceived++; - } + _stats.AddBytesReceived(data.Length); + _stats.IncrementMessagesReceived(); string stringData = null; - bool isBinary = true; + bool isBinary = false; try { - stringData = Encoding.UTF8.GetString(data); - if (Encoding.UTF8.GetByteCount(stringData) == data.Length) + int requiredChars = client.Utf8Decoder.GetCharCount(data, 0, data.Length); + if (client.CharBuffer == null || client.CharBuffer.Length < requiredChars) { - isBinary = false; + client.CharBuffer = new char[requiredChars]; + } + + int charCount = client.Utf8Decoder.GetChars( + data, + 0, + data.Length, + client.CharBuffer, + 0, + false); + + if (charCount > 0) + { + stringData = new string(client.CharBuffer, 0, charCount); } } catch { - stringData = null; + // If decoding fails, treat as binary isBinary = true; + stringData = null; } - if (stringData != null) + if (!string.IsNullOrEmpty(stringData)) { ProcessPingPong(client, ref stringData); + if (string.IsNullOrEmpty(stringData)) { return; @@ -783,52 +1545,75 @@ namespace EonaCat.Connections if (!string.IsNullOrEmpty(stringData)) { - if (stringData.StartsWith("[NICKNAME]", StringComparison.OrdinalIgnoreCase)) + if (stringData.Contains(Configuration.SSL_ERROR_PREFIX, StringComparison.OrdinalIgnoreCase)) + { + string sslError = StringHelper.GetTextBetweenTags(stringData, Configuration.SSL_ERROR_PREFIX, Configuration.SSL_ERROR_SUFFIX); + RecordError(null, $"Client reported SSL error: {sslError}", client.Id, client.Nickname, isSslError: true); + OnSslError?.Invoke(this, new ErrorEventArgs + { + ClientId = client.Id, + Nickname = client.Nickname, + Message = $"Client reported SSL error: {sslError}" + }); + DebugConnection($"[SSL_ERROR] detected from client {client.Id}: {sslError}"); + await DisconnectClientAsync(client.Id, DisconnectReason.SSLError).ConfigureAwait(false); + return; + } + else if (stringData.Contains("[NICKNAME]", StringComparison.OrdinalIgnoreCase)) { string nickname = StringHelper.GetTextBetweenTags(stringData, "[NICKNAME]", "[/NICKNAME]"); HandleNickname(client, nickname); - stringData = stringData.Replace($"[NICKNAME]{nickname}[/NICKNAME]", string.Empty).Trim(); + stringData = Regex.Replace( + stringData, + "\\[NICKNAME\\].*?\\[/NICKNAME\\]", + string.Empty, + RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (string.IsNullOrEmpty(stringData)) { return; } } - else if (stringData.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) + else if (stringData.Contains("[DISCONNECT]", StringComparison.OrdinalIgnoreCase)) { - await DisconnectClientAsync(client.Id, DisconnectReason.ClientRequested) - .ConfigureAwait(false); + DebugConnection($"[DISCONNECT] detected, disconnecting client {client.Id}"); + await DisconnectClientAsync(client.Id, DisconnectReason.ClientRequested).ConfigureAwait(false); return; } } - if (stringData != null) + var eventArgs = new DataReceivedEventArgs { - var pooledCopy = _arrayPool.Rent(data.Length); - Buffer.BlockCopy(data, 0, pooledCopy, 0, data.Length); + ClientId = client.Id, + Nickname = client.Nickname, + RemoteEndPoint = client.RemoteEndPoint, + Data = data, + IsBinary = isBinary, + Timestamp = now + }; - try + try + { + OnDataReceived?.Invoke(this, eventArgs); + } + catch (Exception ex) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs { - 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); - } + ClientId = client.Id, + Nickname = client.Nickname, + Exception = ex, + Message = "Error in OnDataReceived event handler" + }); } } catch (Exception ex) { + _stats.IncrementDroppedPackets(); + var handler = client.IsEncrypted ? OnEncryptionError : OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, @@ -837,328 +1622,29 @@ namespace EonaCat.Connections Message = "Error processing data" }); } - finally - { - 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 + // Only update and fire once per client, and only when nickname actually changes + var previousNickname = client.Nickname; + if (!string.Equals(previousNickname, nickname, StringComparison.Ordinal)) { - ClientId = client.Id, - RemoteEndPoint = client.RemoteEndPoint, - Nickname = nickname - }); - } - else - { - OnConnected?.Invoke(this, new ConnectionEventArgs - { - ClientId = client.Id, - RemoteEndPoint = client.RemoteEndPoint, - Nickname = client.Id - }); - } - } + client.Nickname = nickname; + _clients[client.Id] = client; - 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) + OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs { - 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 + ClientId = client.Id, + RemoteEndPoint = client.RemoteEndPoint, + Nickname = nickname + }); } } } - 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)) - { - return; - } - - if (!client.MarkDisconnected()) - { - return; - } - - try - { - client.DisconnectionTime = DateTime.UtcNow; - - try - { - client.CancellationToken?.Cancel(); - } - catch - { - // 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(); @@ -1173,7 +1659,8 @@ 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)) { - 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); @@ -1190,92 +1677,745 @@ namespace EonaCat.Connections return result.ToList(); } - private void ProcessPingPong(Connection client, ref string message) + public async Task SendToClientAsync(String clientId, byte[] data) { - if (!_config.EnableHeartbeat || string.IsNullOrEmpty(message)) + 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; + } + + var clients = _clients.Values.ToArray(); + if (clients.Length == 0) + { + return false; + } + + var tasks = new Task[clients.Length]; + for (int i = 0; i < clients.Length; i++) + { + tasks[i] = SendDataAsync(clients[i], data); + } + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + return Array.Exists(results, r => r); + } + + public async Task BroadcastAsync(String message) => await BroadcastAsync(Encoding.UTF8.GetBytes(message)); + + public async Task SendToClientAndWaitForResponseAsync(string clientId, byte[] data, TimeSpan? timeout = null) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(DEFAULT_WAITING_TIMEOUT_IN_SECONDS); + var clientList = GetClient(clientId); + var clientIds = new HashSet(clientList.Select(c => c.Id)); + + void Handler(object sender, DataReceivedEventArgs e) + { + if (clientIds.Contains(e.ClientId)) + { + tcs.TrySetResult(e); + } + } + + OnDataReceived += Handler; + try + { + var sent = await SendToClientAsync(clientId, data).ConfigureAwait(false); + if (!sent) + { + return null; + } + + using var cts = new CancellationTokenSource(effectiveTimeout); + using var registration = cts.Token.Register(() => tcs.TrySetCanceled()); + + try + { + return await tcs.Task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return null; + } + } + finally + { + OnDataReceived -= Handler; + } + } + + public async Task SendToClientAndWaitForResponseAsync(string clientId, string message, TimeSpan? timeout = null) + => await SendToClientAndWaitForResponseAsync(clientId, Encoding.UTF8.GetBytes(message), timeout).ConfigureAwait(false); + + public async Task SendFromClientToClientAndWaitForResponseAsync(string fromClientId, string toClientId, byte[] data, TimeSpan? timeout = null) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(DEFAULT_WAITING_TIMEOUT_IN_SECONDS); + var clientList = GetClient(toClientId); + var clientIds = new HashSet(clientList.Select(c => c.Id)); + + void Handler(object sender, DataReceivedEventArgs e) + { + if (clientIds.Contains(e.ClientId)) + { + tcs.TrySetResult(e); + } + } + + OnDataReceived += Handler; + try + { + var sent = await SendFromClientToClientAsync(fromClientId, toClientId, data).ConfigureAwait(false); + if (!sent) + { + return null; + } + + using var cts = new CancellationTokenSource(effectiveTimeout); + using var registration = cts.Token.Register(() => tcs.TrySetCanceled()); + + try + { + return await tcs.Task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return null; + } + } + finally + { + OnDataReceived -= Handler; + } + } + + public async Task SendFromClientToClientAndWaitForResponseAsync(string fromClientId, string toClientId, string message, TimeSpan? timeout = null) + => await SendFromClientToClientAndWaitForResponseAsync(fromClientId, toClientId, Encoding.UTF8.GetBytes(message), timeout).ConfigureAwait(false); + + public async Task> BroadcastAndWaitForResponsesAsync(byte[] data, TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(DEFAULT_WAITING_TIMEOUT_IN_SECONDS); + var responses = new List(); + var expectedCount = _clients.Count; + + if (expectedCount == 0) + { + return responses; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void Handler(object sender, DataReceivedEventArgs e) + { + lock (responses) + { + responses.Add(e); + if (responses.Count >= expectedCount) + { + tcs.TrySetResult(true); + } + } + } + + OnDataReceived += Handler; + try + { + var sent = await BroadcastAsync(data).ConfigureAwait(false); + if (!sent) + { + return responses; + } + + using var cts = new CancellationTokenSource(effectiveTimeout); + using var registration = cts.Token.Register(() => tcs.TrySetResult(true)); + + await tcs.Task.ConfigureAwait(false); + return responses; + } + finally + { + OnDataReceived -= Handler; + } + } + + public async Task> BroadcastAndWaitForResponsesAsync(string message, TimeSpan? timeout = null) + => await BroadcastAndWaitForResponsesAsync(Encoding.UTF8.GetBytes(message), timeout).ConfigureAwait(false); + + private async Task SendDataAsync(Connection client, byte[] data) + { + if (client == null || + !client.IsConnected || + data == null || + data.Length == 0) + { + return false; + } + + if (_config.Protocol == ProtocolType.TCP && + (client.Stream == null || !client.Stream.CanWrite)) + { + return false; + } + + byte[] rentedBuffer = null; + int bytesToSend = 0; + + try + { + // Encrypt BEFORE framing so the frame header describes the encrypted payload length + byte[] payloadToFrame = data; + if (client.IsEncrypted && client.AesEncryption != null) + { + payloadToFrame = await AesKeyExchange.EncryptDataAsync(data, data.Length, client.AesEncryption).ConfigureAwait(false); + } + + int framingOverhead = _config.MessageFraming switch + { + FramingMode.LengthPrefixed => _config.LengthPrefixedLength, + FramingMode.Delimiter => _config.Delimiter?.Length ?? 0, + _ => 0 + }; + + int requiredSize = payloadToFrame.Length + framingOverhead; + + if (requiredSize > MAX_BUFFER_MEMORY_IN_KILOBYTES * 1024) + { + throw new InvalidOperationException("Message exceeds maximum allowed buffer size."); + } + + rentedBuffer = ArrayPool.Shared.Rent(requiredSize); + + bytesToSend = BuildMessage(rentedBuffer, payloadToFrame, _config); + + if (_config.Protocol == ProtocolType.TCP) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(client.CancellationToken?.Token ?? CancellationToken.None); + cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds)); + + await client.WriteLock.WaitAsync(cts.Token).ConfigureAwait(false); + try + { + if (client.Stream == null || !client.Stream.CanWrite) + { + return false; + } + + await client.Stream.WriteAsync(rentedBuffer, 0, bytesToSend, cts.Token).ConfigureAwait(false); + await client.Stream.FlushAsync(cts.Token).ConfigureAwait(false); + } + finally + { + client.WriteLock.Release(); + } + } + else + { + await _udpLock.WaitAsync().ConfigureAwait(false); + try + { + if (_udpListener == null) + { + return false; + } + + await _udpListener.SendAsync(rentedBuffer, bytesToSend, client.RemoteEndPoint).ConfigureAwait(false); + } + finally + { + _udpLock.Release(); + } + } + + client.LastDataSent = DateTime.UtcNow; + client.AddBytesSent(bytesToSend); + + _stats.AddBytesSent(bytesToSend); + _stats.IncrementMessagesSent(); + + return true; + } + catch (OperationCanceledException ex) + { + DebugConnection($"Write timeout for client {client.Nickname} ({client.Id}): {ex.Message} => disconnecting client"); + RecordError(ex, "Write timeout in send", client.Id, client.Nickname); + await SafeDisconnectAsync(client, DisconnectReason.Timeout).ConfigureAwait(false); + return false; + } + catch (SocketException socketEx) + { + DebugConnection($"Socket error sending data to client {client.Nickname} ({client.Id}): {socketEx.SocketErrorCode} - {socketEx.Message}"); + RecordError(socketEx, $"Socket error in send: {socketEx.SocketErrorCode}", client.Id, client.Nickname); + await SafeDisconnectAsync(client, DisconnectReason.Error).ConfigureAwait(false); + return false; + } + catch (IOException ioEx) when (ioEx.InnerException is SocketException innerSocketEx) + { + DebugConnection($"Socket IO error sending data to client {client.Nickname} ({client.Id}): {innerSocketEx.SocketErrorCode} - {ioEx.Message}"); + RecordError(ioEx, $"Socket IO error in send: {innerSocketEx.SocketErrorCode}", client.Id, client.Nickname); + await SafeDisconnectAsync(client, DisconnectReason.Error).ConfigureAwait(false); + return false; + } + catch (Exception exception) + { + RecordError(exception, $"Error sending data: {exception.Message}", client.Id, client.Nickname); + var handler = client.IsEncrypted ? OnEncryptionError : OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs + { + ClientId = client.Id, + Nickname = client.Nickname, + Exception = exception, + Message = "Error sending data" + }); + + await SafeDisconnectAsync(client, DisconnectReason.Error).ConfigureAwait(false); + return false; + } + finally + { + if (rentedBuffer != null) + { + ArrayPool.Shared.Return(rentedBuffer, clearArray: true); + } + } + } + + private static int BuildMessage(byte[] buffer, byte[] data, Configuration config) + { + int offset = 0; + + switch (config.MessageFraming) + { + case FramingMode.LengthPrefixed: + { + int prefixSize = config.LengthPrefixedLength; + + if (prefixSize < 1 || prefixSize > sizeof(long)) + { + throw new ArgumentOutOfRangeException(nameof(config.LengthPrefixedLength)); + } + + long length = data.Length; + + // Make sure length fits in the chosen prefix size + long maxLength = (1L << (prefixSize * 8)) - 1; + if (length > maxLength) + { + throw new InvalidOperationException("Data length exceeds length prefix capacity."); + } + + if (buffer.Length < offset + prefixSize + data.Length) + { + throw new InvalidOperationException("Buffer too small for length-prefixed message."); + } + + if (config.UseBigEndian) + { + for (int i = prefixSize - 1; i >= 0; i--) + { + buffer[offset + i] = (byte)(length & 0xFF); + length >>= 8; + } + } + else + { + for (int i = 0; i < prefixSize; i++) + { + buffer[offset + i] = (byte)(length & 0xFF); + length >>= 8; + } + } + + offset += prefixSize; + + Array.Copy(data, 0, buffer, offset, data.Length); + offset += data.Length; + break; + } + + case FramingMode.Delimiter: + { + int delimiterLength = config.Delimiter?.Length ?? 0; + + if (buffer.Length < data.Length + delimiterLength) + { + throw new InvalidOperationException("Buffer too small for delimited message."); + } + + Array.Copy(data, 0, buffer, offset, data.Length); + offset += data.Length; + + if (delimiterLength > 0) + { + Array.Copy(config.Delimiter, 0, buffer, offset, delimiterLength); + offset += delimiterLength; + } + + break; + } + + case FramingMode.None: + default: + { + if (buffer.Length < data.Length) + { + throw new InvalidOperationException("Buffer too small for message."); + } + + 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.TryGetValue(clientId, out var client)) { 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"); - } + // Always remove from clients dict on first call, even if already marked for disconnection + bool isFirstDisconnect = client.MarkDisconnected(); + _clients.TryRemove(clientId, out _); + _lastPingTimes.TryRemove(clientId, out _); - SendToClientAsync(client.Id, Configuration.PONG_VALUE).ConfigureAwait(false); - message = message.Replace(Configuration.PING_VALUE, string.Empty); + if (!isFirstDisconnect) + { + // Already disconnected, skip cleanup + return; } - if (message.Contains(Configuration.PONG_VALUE)) + if (reason == DisconnectReason.Error + || reason == DisconnectReason.Timeout + || reason == DisconnectReason.SSLError + || reason == DisconnectReason.NoPongReceived + || reason == DisconnectReason.ProtocolError) { - if (_config.EnablePingPongLogs) - { - OnLog?.Invoke(this, $"[PING] Client {client.Id} PONG received for PING sent"); - } + _stats.IncrementDroppedConnections(); + } - OnClientPingResponse?.Invoke(this, new PingEventArgs + client.IsClosing = true; + + try + { + client.DisconnectionTime = DateTime.UtcNow; + +#if NET8_0_OR_GREATER + try { - Id = client.Id, + await (client.CancellationToken?.CancelAsync() ?? Task.CompletedTask); + } + catch + { + // Do nothing + } +#else + try + { + client.CancellationToken?.Cancel(); + } + catch + { + // Do nothing + } +#endif + + try + { + if (client.Stream != null) + { + try + { + await client.Stream.FlushAsync().ConfigureAwait(false); +#if NET8_0_OR_GREATER + try + { + await client.Stream.DisposeAsync(); + } + catch + { + // Do nothing + } +#else + client.Stream.Close(); + client.Stream.Dispose(); +#endif + } + catch + { + // Do nothing + } + } + + OnDisconnected?.Invoke(this, new ConnectionEventArgs + { + ClientId = clientId, + Nickname = client.Nickname, + RemoteEndPoint = client.RemoteEndPoint, + Reason = reason, + Exception = exception + }); + DebugConnection($"Client disconnected: {client.Nickname} ({client.Id}) from {client.RemoteEndPoint}. Reason: {reason}. Total connections: {ActiveConnections}"); + ForceCloseTcpClient(client.TcpClient); + client.UdpClient?.Close(); + client.UdpClient?.Dispose(); + client.AesEncryption?.Clear(); + client.AesEncryption?.Dispose(); + client.Utf8Decoder.Reset(); + + // Dispose WriteLock + client.WriteLock?.Dispose(); + + // Dispose CancellationTokenSource + client.CancellationToken?.Dispose(); + } + catch + { + // Do nothing + } + } + catch (Exception ex) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + ClientId = clientId, Nickname = client.Nickname, - ReceivedTime = DateTime.UtcNow, - RemoteEndPoint = client.RemoteEndPoint + Exception = ex, + Message = "Error while disconnecting client" }); - message = message.Replace(Configuration.PONG_VALUE, string.Empty); + } + } + + private static void ForceCloseTcpClient(TcpClient? tcpClient) + { + if (tcpClient == null) + { + return; + } + + try + { + tcpClient.Client?.Shutdown(SocketShutdown.Both); + } + catch + { + // Do nothing + } + + try + { + tcpClient.Close(); + } + catch + { + // Do nothing + } + + try + { + tcpClient.Dispose(); + } + catch + { + // Do nothing + } + } + + private async Task SafeDisconnectAsync(Connection client, DisconnectReason reason) + { + if (client != null) + { + try + { + await DisconnectClientAsync(client.Id, reason).ConfigureAwait(false); + } + catch + { + } } } public void Stop() { - try + _ = StopAsync(); + } + + public async Task StopAsync() + { + if (_isStarting) { - _serverCancellation?.Cancel(); + return; } - catch + + if (Interlocked.CompareExchange(ref _tcpRunning, 0, 1) == 0 && + Interlocked.CompareExchange(ref _udpRunning, 0, 1) == 0) { - // Do nothing + return; } - - try + +#if NET8_0_OR_GREATER + try + { + await (_serverCancellation?.CancelAsync() ?? Task.CompletedTask); + } + catch + { + // Do nothing + } +#else + _serverCancellation?.Cancel(); +#endif + + _tcpListener?.Stop(); + + var clientIds = _clients.Keys.ToList(); + + var disconnectTasks = clientIds + .Select(id => DisconnectClientAsync(id, DisconnectReason.ServerShutdown)) + .ToList(); + + try { - _pingCancellation?.Cancel(); + await Task.WhenAll(disconnectTasks).ConfigureAwait(false); } catch { // Do nothing } - try + if (_config.Protocol == ProtocolType.TCP) { - _pongCancellation?.Cancel(); + try + { + await StopTcpServerAsync().ConfigureAwait(false); + } + catch + { + // Do nothing + } } - catch + else if (_config.Protocol == ProtocolType.UDP) + { + try + { + await StopUdpServerAsync().ConfigureAwait(false); + } + catch + { + // Do nothing + } + } + + await AwaitAndDisposeAsync(_tcpTask); + await AwaitAndDisposeAsync(_acceptTcpClientsTask); + await AwaitAndDisposeAsync(_udpTask); + await AwaitAndDisposeAsync(_pingTask); + await AwaitAndDisposeAsync(_pongTask); + await AwaitAndDisposeAsync(_cleanupTask); + + DisposeAndNull(ref _serverCancellation); + _udpLock?.Dispose(); + + StatusPage.StopAutoHtmlReport(); + ServerStatus.StopAutoReport(); + HealthApi.Stop(); + + _tcpListener = null; + _clients.Clear(); + _lastPingTimes.Clear(); + } + + private static async Task AwaitAndDisposeAsync(Task? task) + { + if (task == null) + { + return; + } + + try + { + await task.ConfigureAwait(false); + } + catch { // Do nothing } - _pingCancellation?.Dispose(); _pongCancellation?.Dispose(); _serverCancellation?.Dispose(); - _pingCancellation = null; _pongCancellation = null; _serverCancellation = null; + task.Dispose(); + } - var disconnectTasks = _clients.Keys.ToArray().Select(id => DisconnectClientAsync(id, DisconnectReason.ServerShutdown)).ToList(); - Task.WaitAll(disconnectTasks.ToArray()); - StopTcpServer(); StopUdpServer(); - _clients.Clear(); _lastPingTimes.Clear(); + private static void DisposeAndNull(ref T disposable) + where T : class, IDisposable + { + disposable?.Dispose(); + disposable = null; } public void Dispose() { - Stop(); - OnConnected = null; - OnDisconnected = null; - OnDataReceived = null; - OnConnectedWithNickname = null; - OnSslError = null; - OnEncryptionError = null; - OnGeneralError = null; - OnClientPingResponse = null; - _serverCancellation?.Dispose(); + _ = StopAsync(); + + ServerStatus.Dispose(); + HealthApi.Dispose(); + + OnConnected = null; + OnDisconnected = null; + OnDataReceived = null; + OnConnectedWithNickname = null; + OnSslError = null; + OnEncryptionError = null; + OnGeneralError = null; + OnClientPingResponse = null; + OnPongMissed = null; + OnSocketError = null; } } } \ No newline at end of file diff --git a/EonaCat.Connections/Processors/JsonDataProcessor.cs b/EonaCat.Connections/Processors/JsonDataProcessor.cs index 417490a..531afec 100644 --- a/EonaCat.Connections/Processors/JsonDataProcessor.cs +++ b/EonaCat.Connections/Processors/JsonDataProcessor.cs @@ -1,252 +1,313 @@ -using EonaCat.Json; -using Heijmans.Connector.Models; -using System; -using System.Text; - -namespace EonaCat.Connections.Processors -{ - public sealed class JsonDataProcessor : IDisposable - { - private readonly StringBuilder _buffer = new StringBuilder(4096); - private readonly object _sync = new object(); - private bool _disposed; - - public int MaxAllowedBufferSize { get; set; } = 30 * 1024 * 1024; - public int MaxMessagesPerBatch { get; set; } = 200; - public string ClientName { get; } - - public long TotalBytesProcessed { get; private set; } - public long TotalChunksReceived { get; private set; } - - public event EventHandler>? OnProcessMessage; - public event EventHandler? OnProcessTextMessage; - public event EventHandler? OnMessageError; - - public JsonDataProcessor() - { - ClientName = Guid.NewGuid().ToString(); - } - - public void Process(string data, string? client = null, string? endpoint = null) - { - ThrowIfDisposed(); - if (string.IsNullOrEmpty(data)) - { - return; - } - - TotalChunksReceived++; - TotalBytesProcessed += Encoding.UTF8.GetByteCount(data); - - ProcessInternal(data, client ?? ClientName, endpoint); - } - - private void ProcessInternal(string data, string client, string? endpoint) - { - lock (_sync) - { - _buffer.Append(data); - - int processed = 0; - while (processed < MaxMessagesPerBatch && - TryExtract(out int start, out int length, out bool isText)) - { - if (isText) - { - var text = _buffer.ToString(start, length); - OnProcessTextMessage?.Invoke(this, new ProcessedTextMessage - { - Text = text, - ClientName = client - }); - } - else - { - var json = _buffer.ToString(start, length); - try - { - var obj = JsonHelper.ToObject(json); - OnProcessMessage?.Invoke(this, new ProcessedJsonMessage - { - Data = obj, - RawData = json, - ClientName = client, - ClientEndpoint = endpoint ?? string.Empty - }); - } - catch (Exception ex) - { - OnMessageError?.Invoke(this, - new Exception($"Failed to parse JSON for {client}", ex)); - } - } - - Consume(start, length); - processed++; - } - - if (_buffer.Length > MaxAllowedBufferSize) - { - OnMessageError?.Invoke(this, - new Exception($"Buffer exceeded {MaxAllowedBufferSize} bytes for client {client}. Discarding.")); - _buffer.Clear(); - _buffer.Capacity = 4096; - } - } - } - - private bool TryExtract(out int start, out int length, out bool isText) - { - start = length = 0; - isText = false; - - if (_buffer.Length == 0) - { - return false; - } - - var span = _buffer.ToString().AsSpan(); - int pos = 0; - - while (pos < span.Length && char.IsWhiteSpace(span[pos])) - { - pos++; - } - - if (pos >= span.Length) - { - return false; - } - - char c = span[pos]; - - if (c != '{' && c != '[') - { - isText = true; - start = pos; - - while (pos < span.Length && span[pos] != '{' && span[pos] != '[') - { - pos++; - } - - length = pos - start; - return true; - } - - start = pos; - length = FindJsonEnd(span, pos) - pos; - if (length <= 0) - { - return false; - } - - isText = false; - return true; - } - - private static int FindJsonEnd(ReadOnlySpan span, int start) - { - char open = span[start]; - char close = open == '{' ? '}' : ']'; - - int depth = 1; - bool inString = false; - bool escape = false; - - for (int i = start + 1; i < span.Length; i++) - { - char c = span[i]; - - if (inString) - { - if (escape) - { - escape = false; - } - else if (c == '\\') - { - escape = true; - } - else if (c == '"') - { - inString = false; - } - } - else - { - if (c == '"') - { - inString = true; - } - else if (c == open) - { - depth++; - } - else if (c == close && --depth == 0) - { - return i + 1; - } - } - } - - return 0; - } - - private void Consume(int start, int length) - { - _buffer.Remove(start, length); - - if (_buffer.Capacity > 1024 * 1024 && _buffer.Length < _buffer.Capacity / 2) - { - _buffer.Capacity = Math.Max(_buffer.Length, 4096); - } - } - - public void ClearBuffer() - { - lock (_sync) - { - _buffer.Clear(); - _buffer.Capacity = 4096; - } - } - - public void ResetStatistics() - { - lock (_sync) - { - TotalBytesProcessed = 0; - TotalChunksReceived = 0; - } - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - lock (_sync) - { - _buffer.Clear(); - _buffer.Capacity = 0; - } - - OnProcessMessage = null; - OnProcessTextMessage = null; - OnMessageError = null; - } - - private void ThrowIfDisposed() - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(JsonDataProcessor)); - } - } - } -} +using EonaCat.Json; +using EonaCat.Connections; +using EonaCat.Connections.Models; +using System.Collections.Concurrent; +using System.Text; +using System.Timers; + +public sealed class JsonDataProcessor : IDisposable +{ + // 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. + + private sealed class ClientContext + { + public JsonChunkParser Parser { get; private set; } + + public string ClientName { get; } + + public string ClientEndpoint { get; set; } + + public DateTime LastActivityUtc { get; set; } + + public object Lock { get; } = new(); + + public EventHandler NonJsonTextHandler { get; set; } + + public EventHandler FullJsonHandler { get; set; } + + public ClientContext(string name, string endpoint, int maxJsonSize) + { + ClientName = name; + ClientEndpoint = endpoint; + Parser = new JsonChunkParser + { + MaxJsonSize = maxJsonSize + }; + LastActivityUtc = DateTime.UtcNow; + } + + public void UnbindEvents() + { + if (NonJsonTextHandler != null) + { + Parser.NonJsonTextFound -= NonJsonTextHandler; + NonJsonTextHandler = null; + } + + if (FullJsonHandler != null) + { + Parser.FullJsonCompleted -= FullJsonHandler; + FullJsonHandler = null; + } + } + + public void ResetParser(int maxJsonSize) + { + UnbindEvents(); + Parser.Dispose(); + + Parser = new JsonChunkParser + { + MaxJsonSize = maxJsonSize + }; + } + } + + public int MaxAllowedBufferSize { get; set; } = 128 * 1024 * 1024; + + public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(10); + + public bool KeepIdleClients { get; set; } + + private readonly ConcurrentDictionary _clients = new(); + + private readonly string _clientName; + + private readonly System.Timers.Timer _cleanupTimer; + + public event EventHandler> OnProcessMessage; + public event EventHandler OnProcessTextMessage; + public event EventHandler OnError; + public event EventHandler OnClientRemovedDueToIdle; + + public JsonDataProcessor(string clientName = null) + { + if (string.IsNullOrWhiteSpace(clientName)) + { + clientName = Guid.NewGuid().ToString(); + } + + _clientName = clientName; + + _cleanupTimer = new System.Timers.Timer(60000); + _cleanupTimer.Elapsed += CleanupTimer_Elapsed; + _cleanupTimer.AutoReset = true; + _cleanupTimer.Start(); + } + + private void CleanupTimer_Elapsed(object sender, ElapsedEventArgs e) + { + if (KeepIdleClients) + { + return; + } + + var now = DateTime.UtcNow; + + foreach (var kvp in _clients) + { + var context = kvp.Value; + + if (now - context.LastActivityUtc < IdleTimeout) + { + continue; + } + + if (_clients.TryRemove(kvp.Key, out var removed)) + { + lock (removed.Lock) + { + removed.UnbindEvents(); + removed.Parser.Dispose(); + } + + SafeInvoke(() => + OnClientRemovedDueToIdle?.Invoke(this, removed.ClientName)); + } + } + } + + public void Process(string jsonChunk, string currentClientName, string clientEndpoint = null) + { + if (string.IsNullOrEmpty(jsonChunk)) + { + RaiseError(new ArgumentException("Invalid JSON input.")); + return; + } + + Process(Encoding.UTF8.GetBytes(jsonChunk), currentClientName, clientEndpoint); + } + + public void Process(DataReceivedEventArgs data, string currentClientName, string clientEndpoint = null) + { + if (data?.Data == null || data.Data.Length == 0) + { + RaiseError(new ArgumentException("Invalid input data.")); + return; + } + + if (string.IsNullOrWhiteSpace(clientEndpoint)) + { + clientEndpoint = data.RemoteEndPoint?.ToString(); + } + + Process(data.Data, currentClientName, clientEndpoint); + } + + public void Process(byte[] data, string currentClientName, string clientEndpoint = null) + { + if (data == null || data.Length == 0) + { + RaiseError(new ArgumentException("Invalid input data.")); + return; + } + + if (string.IsNullOrWhiteSpace(currentClientName)) + { + currentClientName = _clientName; + } + + try + { + var context = _clients.GetOrAdd(currentClientName, name => + { + var ctx = new ClientContext(name, clientEndpoint, MaxAllowedBufferSize); + BindEvents(ctx); + return ctx; + }); + + lock (context.Lock) + { + if (!string.IsNullOrWhiteSpace(clientEndpoint)) + { + context.ClientEndpoint = clientEndpoint; + } + + context.LastActivityUtc = DateTime.UtcNow; + + context.Parser.Process(data); + + if (context.Parser.MaxJsonSize > MaxAllowedBufferSize) + { + context.ResetParser(MaxAllowedBufferSize); + BindEvents(context); + + RaiseError(new Exception("Parser reset due to buffer overflow.")); + } + } + } + catch (Exception ex) + { + RaiseError(new Exception($"Could not process chunk: {ex.Message}", ex)); + } + } + + private void BindEvents(ClientContext context) + { + context.UnbindEvents(); + + context.NonJsonTextHandler = (sender, e) => + { + if (e == null || e.Text.Length == 0) + { + return; + } + + SafeInvoke(() => + OnProcessTextMessage?.Invoke(this, + new ProcessedTextMessage + { + ClientName = context.ClientName, + ClientEndpoint = context.ClientEndpoint, + Text = e.Text.ToString() + })); + }; + + context.FullJsonHandler = (sender, e) => + { + if (e.Json.Length == 0) + { + return; + } + + try + { + var data = JsonHelper.ToObject(e.Json); + + SafeInvoke(() => + OnProcessMessage?.Invoke(this, + new ProcessedMessage + { + ClientName = context.ClientName, + ClientEndpoint = context.ClientEndpoint, + Data = data + })); + } + catch (Exception ex) + { + RaiseError(new Exception($"JSON parse error: {ex.Message}", ex)); + } + }; + + context.Parser.NonJsonTextFound += context.NonJsonTextHandler; + context.Parser.FullJsonCompleted += context.FullJsonHandler; + } + + public void RemoveClient(string clientName) + { + if (string.IsNullOrWhiteSpace(clientName)) + { + return; + } + + if (_clients.TryRemove(clientName, out var context)) + { + lock (context.Lock) + { + context.UnbindEvents(); + context.Parser.Dispose(); + } + } + } + + private void RaiseError(Exception ex) + { + SafeInvoke(() => OnError?.Invoke(this, ex)); + } + + private static void SafeInvoke(Action action) + { + try + { + action?.Invoke(); + } + catch + { + // prevent user event handlers from breaking the processor + } + } + + public void Dispose() + { + _cleanupTimer.Elapsed -= CleanupTimer_Elapsed; + _cleanupTimer.Stop(); + _cleanupTimer.Dispose(); + + foreach (var client in _clients.Values) + { + lock (client.Lock) + { + client.UnbindEvents(); + client.Parser.Dispose(); + } + } + + _clients.Clear(); + + OnProcessMessage = null; + OnProcessTextMessage = null; + OnError = null; + OnClientRemovedDueToIdle = null; + } +} \ No newline at end of file diff --git a/EonaCat.Connections/icon.ico b/EonaCat.Connections/icon.ico new file mode 100644 index 0000000..406f265 Binary files /dev/null and b/EonaCat.Connections/icon.ico differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..0595b89 Binary files /dev/null and b/icon.png differ