From b410c033dd4f7dcf2491e7c7f200d682032f67bf Mon Sep 17 00:00:00 2001 From: EonaCat Date: Wed, 17 Jun 2026 08:05:50 +0200 Subject: [PATCH] Updated --- .../EonaCat.Connections.Client.csproj | 8 + EonaCat.Connections.Client/JsonTest.cs | 141 +- EonaCat.Connections.Client/Program.cs | 21 +- .../EonaCat.Connections.Server.csproj | 36 +- EonaCat.Connections.Server/Program.cs | 311 +- EonaCat.Connections/BufferSize.cs | 32 +- EonaCat.Connections/DisconnectReason.cs | 40 +- .../EonaCat.Connections.csproj | 6 +- .../EventArguments/ConnectionEventArgs.cs | 158 +- .../EventArguments/DataReceivedEventArgs.cs | 37 +- .../EventArguments/ErrorEventArgs.cs | 10 +- .../EventArguments/IdleClientEventArgs.cs | 30 + .../EventArguments/IdleEventArgs.cs | 24 + .../EventArguments/PingEventArgs.cs | 28 +- EonaCat.Connections/Helpers/AesKeyExchange.cs | 480 ++- .../Helpers/HealthApiServer.cs | 325 ++ EonaCat.Connections/Helpers/NetworkMonitor.cs | 652 ++++ .../Helpers/SslHandshakeDiagnostics.cs | 263 ++ EonaCat.Connections/Helpers/SslMetrics.cs | 126 + EonaCat.Connections/Helpers/StringHelper.cs | 46 +- EonaCat.Connections/Helpers/TcpSeparators.cs | 76 +- EonaCat.Connections/Models/Configuration.cs | 269 +- EonaCat.Connections/Models/Connection.cs | 586 ++-- EonaCat.Connections/Models/FramingMode.cs | 22 +- .../Models/ProcessedMessage.cs | 36 +- .../Models/ProcessedTextMessage.cs | 27 +- EonaCat.Connections/Models/ProtocolType.cs | 10 +- .../Models/ServerStatusPage.cs | 359 +++ .../Models/SocketErrorEntry.cs | 32 + .../Models/SocketStatusPage.cs | 521 ++++ EonaCat.Connections/Models/Stats.cs | 100 +- EonaCat.Connections/NetworkClient.cs | 2733 ++++++++++++----- EonaCat.Connections/NetworkServer.cs | 2628 +++++++++++----- .../Processors/JsonDataProcessor.cs | 565 ++-- EonaCat.Connections/icon.ico | Bin 0 -> 254014 bytes icon.png | Bin 0 -> 89562 bytes 36 files changed, 7905 insertions(+), 2833 deletions(-) create mode 100644 EonaCat.Connections/EventArguments/IdleClientEventArgs.cs create mode 100644 EonaCat.Connections/EventArguments/IdleEventArgs.cs create mode 100644 EonaCat.Connections/Helpers/HealthApiServer.cs create mode 100644 EonaCat.Connections/Helpers/NetworkMonitor.cs create mode 100644 EonaCat.Connections/Helpers/SslHandshakeDiagnostics.cs create mode 100644 EonaCat.Connections/Helpers/SslMetrics.cs create mode 100644 EonaCat.Connections/Models/ServerStatusPage.cs create mode 100644 EonaCat.Connections/Models/SocketErrorEntry.cs create mode 100644 EonaCat.Connections/Models/SocketStatusPage.cs create mode 100644 EonaCat.Connections/icon.ico create mode 100644 icon.png 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 0000000000000000000000000000000000000000..406f2659ac50172f9307c0af9038f473b50b6598 GIT binary patch literal 254014 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 z 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 IMBDrS02~H<8~^|S literal 0 HcmV?d00001