Added plugin system
This commit is contained in:
		| @@ -61,7 +61,7 @@ namespace EonaCat.Connections.Client.Example | ||||
|                 Console.WriteLine($"Connected to server at {e.RemoteEndPoint}"); | ||||
|  | ||||
|                 // Set nickname | ||||
|                 await _client.SetNicknameAsync("TestUser"); | ||||
|                 await _client.SendNicknameAsync("TestUser"); | ||||
|  | ||||
|                 // Send a message | ||||
|                 await _client.SendAsync("Hello server!"); | ||||
|   | ||||
							
								
								
									
										23
									
								
								EonaCat.Connections/DisconnectReason.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								EonaCat.Connections/DisconnectReason.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| 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, | ||||
|         ServerShutdown, | ||||
|         Reconnect, | ||||
|         ClientRequested, | ||||
|         Forced | ||||
|     } | ||||
| } | ||||
| @@ -11,7 +11,7 @@ | ||||
|     <Copyright>EonaCat (Jeroen Saey)</Copyright> | ||||
|     <PackageReadmeFile>readme.md</PackageReadmeFile> | ||||
|     <PackageId>EonaCat.Connections</PackageId> | ||||
|     <Version>1.0.7</Version> | ||||
|     <Version>1.0.8</Version> | ||||
|     <Authors>EonaCat (Jeroen Saey)</Authors> | ||||
|     <PackageLicenseFile>LICENSE</PackageLicenseFile> | ||||
|     <PackageIcon>EonaCat.png</PackageIcon> | ||||
| @@ -36,6 +36,7 @@ | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="EonaCat.Json" Version="1.1.9" /> | ||||
|     <PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.3" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System.Net; | ||||
| using System.Net.Sockets; | ||||
|  | ||||
| namespace EonaCat.Connections.EventArguments | ||||
| { | ||||
| @@ -9,8 +10,72 @@ namespace EonaCat.Connections.EventArguments | ||||
|     { | ||||
|         public string ClientId { get; set; } | ||||
|         public string Nickname { get; set; } | ||||
|         public bool HasNickname => !string.IsNullOrEmpty(Nickname); | ||||
|         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 ex) | ||||
|         { | ||||
|             if (ex == null) | ||||
|             { | ||||
|                 return reason; | ||||
|             } | ||||
|  | ||||
|             if (ex is SocketException socketEx) | ||||
|             { | ||||
|                 switch (socketEx.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 (ex is ObjectDisposedException || ex is InvalidOperationException) | ||||
|             { | ||||
|                 return DisconnectReason.LocalClosed; | ||||
|             } | ||||
|  | ||||
|             if (ex.Message.Contains("An existing connection was forcibly closed by the remote host") | ||||
|                 || ex.Message.Contains("The remote party has closed the transport stream")) | ||||
|             { | ||||
|                 return DisconnectReason.RemoteClosed; | ||||
|             } | ||||
|  | ||||
|             return DisconnectReason.Error; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,105 +0,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 AesCryptoHelpers | ||||
|     { | ||||
|         private static readonly byte[] HmacInfo = Encoding.UTF8.GetBytes("EonaCat.Connections.HMAC"); | ||||
|  | ||||
|         public static async Task<byte[]> EncryptDataAsync(byte[] plaintext, Aes aes) | ||||
|         { | ||||
|             byte[] iv = new byte[aes.BlockSize / 8]; | ||||
|             using (var rng = RandomNumberGenerator.Create()) | ||||
|             { | ||||
|                 rng.GetBytes(iv); | ||||
|             } | ||||
|  | ||||
|             byte[] ciphertext; | ||||
|             using (var encryptor = aes.CreateEncryptor(aes.Key, iv)) | ||||
|             using (var ms = new MemoryStream()) | ||||
|             using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) | ||||
|             { | ||||
|                 await cs.WriteAsync(plaintext, 0, plaintext.Length); | ||||
|                 cs.FlushFinalBlock(); | ||||
|                 ciphertext = ms.ToArray(); | ||||
|             } | ||||
|  | ||||
|             byte[] hmacKey = DeriveHmacKey(aes.Key); | ||||
|             byte[] toAuth = iv.Concat(ciphertext).ToArray(); | ||||
|             byte[] hmac; | ||||
|             using (var h = new HMACSHA256(hmacKey)) | ||||
|             { | ||||
|                 hmac = h.ComputeHash(toAuth); | ||||
|             } | ||||
|  | ||||
|             return toAuth.Concat(hmac).ToArray(); | ||||
|         } | ||||
|  | ||||
|         public static async Task<byte[]> DecryptDataAsync(byte[] payload, Aes aes) | ||||
|         { | ||||
|             int ivLen = aes.BlockSize / 8; | ||||
|             int hmacLen = 32; | ||||
|  | ||||
|             if (payload.Length < ivLen + hmacLen) | ||||
|             { | ||||
|                 throw new CryptographicException("Payload too short"); | ||||
|             } | ||||
|  | ||||
|             byte[] iv = payload.Take(ivLen).ToArray(); | ||||
|             byte[] ciphertext = payload.Skip(ivLen).Take(payload.Length - ivLen - hmacLen).ToArray(); | ||||
|             byte[] receivedHmac = payload.Skip(payload.Length - hmacLen).ToArray(); | ||||
|  | ||||
|             byte[] hmacKey = DeriveHmacKey(aes.Key); | ||||
|             byte[] toAuth = iv.Concat(ciphertext).ToArray(); | ||||
|             byte[] computed; | ||||
|             using (var h = new HMACSHA256(hmacKey)) | ||||
|             { | ||||
|                 computed = h.ComputeHash(toAuth); | ||||
|             } | ||||
|  | ||||
|             if (!FixedTimeEquals(computed, receivedHmac)) | ||||
|             { | ||||
|                 throw new CryptographicException("HMAC validation failed: message tampered or wrong key"); | ||||
|             } | ||||
|  | ||||
|             byte[] plaintext; | ||||
|             using (var decryptor = aes.CreateDecryptor(aes.Key, iv)) | ||||
|             using (var ms = new MemoryStream(ciphertext)) | ||||
|             using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) | ||||
|             using (var result = new MemoryStream()) | ||||
|             { | ||||
|                 await cs.CopyToAsync(result); | ||||
|                 plaintext = result.ToArray(); | ||||
|             } | ||||
|  | ||||
|             return plaintext; | ||||
|         } | ||||
|  | ||||
|         private static byte[] DeriveHmacKey(byte[] aesKey) | ||||
|         { | ||||
|             using var h = new HMACSHA256(aesKey); | ||||
|             return h.ComputeHash(HmacInfo); | ||||
|         } | ||||
|  | ||||
|         private static bool FixedTimeEquals(byte[] a, byte[] b) | ||||
|         { | ||||
|             if (a.Length != b.Length) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             int diff = 0; | ||||
|             for (int i = 0; i < a.Length; i++) | ||||
|             { | ||||
|                 diff |= a[i] ^ b[i]; | ||||
|             } | ||||
|  | ||||
|             return diff == 0; | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
|  | ||||
| namespace EonaCat.Connections.Helpers | ||||
| { | ||||
| @@ -7,72 +8,237 @@ namespace EonaCat.Connections.Helpers | ||||
|  | ||||
|     public static class AesKeyExchange | ||||
|     { | ||||
|         private const int _saltSize = 16; | ||||
|         private const int _keySize = 32; | ||||
|         // 256-bit salt | ||||
|         private const int _saltSize = 32; | ||||
|  | ||||
|         // 128-bit IV | ||||
|         private const int _ivSize = 16; | ||||
|         private const int _hmacSize = 32; | ||||
|         private const int _pbkdf2Iterations = 100_000; | ||||
|  | ||||
|         // Returns an AES object derived from the password and salt | ||||
|         public static async Task<Aes> ReceiveAesKeyAsync(Stream stream, string password) | ||||
|         // 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<byte[]> EncryptDataAsync(byte[] data, Aes aes) | ||||
|         { | ||||
|             // Read salt | ||||
|             byte[] salt = new byte[_saltSize]; | ||||
|             await stream.ReadExactlyAsync(salt, 0, _saltSize); | ||||
|  | ||||
|             // Derive key | ||||
|             byte[] key; | ||||
|             using (var kdf = new Rfc2898DeriveBytes(password, salt, _pbkdf2Iterations, HashAlgorithmName.SHA256)) | ||||
|             using (var encryptor = aes.CreateEncryptor()) | ||||
|             using (var ms = new MemoryStream()) | ||||
|             using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) | ||||
|             { | ||||
|                 key = kdf.GetBytes(_keySize); | ||||
|                 await cs.WriteAsync(data, 0, data.Length); | ||||
|                 cs.FlushFinalBlock(); | ||||
|                 return ms.ToArray(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public static async Task<byte[]> DecryptDataAsync(byte[] data, Aes aes) | ||||
|         { | ||||
|             using (var decryptor = aes.CreateDecryptor()) | ||||
|             using (var ms = new MemoryStream(data)) | ||||
|             using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) | ||||
|             using (var result = new MemoryStream()) | ||||
|             { | ||||
|                 await cs.CopyToAsync(result); | ||||
|                 return result.ToArray(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public static async Task<Aes> SendAesKeyAsync(Stream stream, Aes aes, string password) | ||||
|         { | ||||
|             if (stream == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(stream)); | ||||
|             } | ||||
|  | ||||
|             var aes = Aes.Create(); | ||||
|             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.BlockSize = 128; | ||||
|             aes.Mode = CipherMode.CBC; | ||||
|             aes.Padding = PaddingMode.PKCS7; | ||||
|             aes.Key = key; | ||||
|             aes.Key = aesKey; | ||||
|             aes.IV = iv; | ||||
|  | ||||
|             return aes; | ||||
|         } | ||||
|  | ||||
|         // Sends salt (no key) to the other side | ||||
|         public static async Task SendAesKeyAsync(Stream stream, Aes aes, string password) | ||||
|         public static async Task<Aes> ReceiveAesKeyAsync(Stream stream, string password) | ||||
|         { | ||||
|             // Generate random salt | ||||
|             byte[] salt = new byte[_saltSize]; | ||||
|             using (var rng = RandomNumberGenerator.Create()) | ||||
|             if (stream == null) | ||||
|             { | ||||
|                 rng.GetBytes(salt); | ||||
|                 throw new ArgumentNullException(nameof(stream)); | ||||
|             } | ||||
|  | ||||
|             // Derive AES key | ||||
|             byte[] key; | ||||
|             using (var kdf = new Rfc2898DeriveBytes(password, salt, _pbkdf2Iterations, HashAlgorithmName.SHA256)) | ||||
|             if (string.IsNullOrWhiteSpace(password)) | ||||
|             { | ||||
|                 key = kdf.GetBytes(_keySize); | ||||
|                 throw new ArgumentException("Password/PSK required", nameof(password)); | ||||
|             } | ||||
|             aes.Key = key; | ||||
|  | ||||
|             // Send salt only | ||||
|             await stream.WriteAsync(salt, 0, salt.Length); | ||||
|             await stream.FlushAsync(); | ||||
|             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 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); | ||||
|                 expected = h.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; | ||||
|         } | ||||
|  | ||||
|         public static async Task ReadExactlyAsync(this Stream stream, byte[] buffer, int offset, int count) | ||||
|  | ||||
|         private static async Task WriteWithLengthAsync(Stream stream, byte[] data) | ||||
|         { | ||||
|             int read = 0; | ||||
|             while (read < count) | ||||
|             var byteLength = BitConverter.GetBytes(data.Length); | ||||
|             if (BitConverter.IsLittleEndian) | ||||
|             { | ||||
|                 int readBytes = await stream.ReadAsync(buffer, offset + read, count - read); | ||||
|                 if (readBytes == 0) | ||||
|                 Array.Reverse(byteLength); | ||||
|             } | ||||
|  | ||||
|             await stream.WriteAsync(byteLength, 0, 4).ConfigureAwait(false); | ||||
|             await stream.WriteAsync(data, 0, data.Length).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         private static async Task<byte[]> 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(); | ||||
|                     throw new EndOfStreamException("Stream ended prematurely"); | ||||
|                 } | ||||
|  | ||||
|                 read += readBytes; | ||||
|                 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 n) | ||||
|         { | ||||
|             var b = new byte[n]; | ||||
|             using (var random = RandomNumberGenerator.Create()) | ||||
|             { | ||||
|                 random.GetBytes(b); | ||||
|             } | ||||
|  | ||||
|             return b; | ||||
|         } | ||||
|  | ||||
|         private static bool FixedTimeEquals(byte[] a, byte[] b) | ||||
|         { | ||||
|             if (a == null || b == null || a.Length != b.Length) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             int difference = 0; | ||||
|             for (int i = 0; i < a.Length; i++) | ||||
|             { | ||||
|                 difference |= a[i] ^ b[i]; | ||||
|             } | ||||
|  | ||||
|             return difference == 0; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										17
									
								
								EonaCat.Connections/IClientPlugin.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								EonaCat.Connections/IClientPlugin.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| namespace EonaCat.Connections | ||||
| { | ||||
|     // This file is part of the EonaCat project(s) which is released under the Apache License. | ||||
|     // See the LICENSE file or go to https://EonaCat.com/license for full license details. | ||||
|  | ||||
|     public interface IClientPlugin | ||||
|     { | ||||
|         string Name { get; } | ||||
|  | ||||
|         void OnClientStarted(NetworkClient client); | ||||
|         void OnClientConnected(NetworkClient client); | ||||
|         void OnClientDisconnected(NetworkClient client, DisconnectReason reason, Exception exception); | ||||
|         void OnDataReceived(NetworkClient client, byte[] data, string stringData, bool isBinary); | ||||
|         void OnError(NetworkClient client, Exception exception, string message); | ||||
|         void OnClientStopped(NetworkClient client); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										55
									
								
								EonaCat.Connections/IServerPlugin.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								EonaCat.Connections/IServerPlugin.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| using EonaCat.Connections.Models; | ||||
|  | ||||
| namespace EonaCat.Connections | ||||
| { | ||||
|     // This file is part of the EonaCat project(s) which is released under the Apache License. | ||||
|     // See the LICENSE file or go to https://EonaCat.com/license for full license details. | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Defines the contract for plugins that extend the behavior of the NetworkServer. | ||||
|     /// Implement this interface to hook into server events such as | ||||
|     /// client connections, disconnections, message handling, and lifecycle events. | ||||
|     /// </summary> | ||||
|     public interface IServerPlugin | ||||
|     { | ||||
|         /// <summary> | ||||
|         /// Gets the unique name of this plugin (used for logging/error reporting). | ||||
|         /// </summary> | ||||
|         string Name { get; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Called when the server has started successfully. | ||||
|         /// </summary> | ||||
|         /// <param name="server">The server instance that started.</param> | ||||
|         void OnServerStarted(NetworkServer server); | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Called when the server has stopped. | ||||
|         /// </summary> | ||||
|         /// <param name="server">The server instance that stopped.</param> | ||||
|         void OnServerStopped(NetworkServer server); | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Called when a client successfully connects. | ||||
|         /// </summary> | ||||
|         /// <param name="client">The connected client.</param> | ||||
|         void OnClientConnected(Connection client); | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Called when a client disconnects. | ||||
|         /// </summary> | ||||
|         /// <param name="client">The client that disconnected.</param> | ||||
|         /// <param name="reason">The reason for disconnection.</param> | ||||
|         /// <param name="exception">Optional exception if the disconnect was caused by an error.</param> | ||||
|         void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception); | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Called when data is received from a client. | ||||
|         /// </summary> | ||||
|         /// <param name="client">The client that sent the data.</param> | ||||
|         /// <param name="data">The raw bytes received.</param> | ||||
|         /// <param name="stringData">The decoded string (if text-based, otherwise null).</param> | ||||
|         /// <param name="isBinary">True if the message is binary data, false if text.</param> | ||||
|         void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary); | ||||
|     } | ||||
| } | ||||
| @@ -14,9 +14,9 @@ namespace EonaCat.Connections.Models | ||||
|         public UdpClient UdpClient { get; set; } | ||||
|         public IPEndPoint RemoteEndPoint { get; set; } | ||||
|         public Stream Stream { get; set; } | ||||
|          | ||||
|  | ||||
|         private string _nickName; | ||||
|         public string Nickname  | ||||
|         public string Nickname | ||||
|         { | ||||
|             get | ||||
|             { | ||||
| @@ -40,14 +40,26 @@ namespace EonaCat.Connections.Models | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id; | ||||
|  | ||||
|         public DateTime ConnectedAt { get; set; } | ||||
|         public DateTime LastActive { get; set; } | ||||
|         public bool IsSecure { get; set; } | ||||
|         public bool IsEncrypted { get; set; } | ||||
|         public Aes AesEncryption { get; set; } | ||||
|         public CancellationTokenSource CancellationToken { get; set; } | ||||
|         public long BytesSent { get; set; } | ||||
|         public long BytesReceived { get; set; } | ||||
|         public SemaphoreSlim SendLock { get; internal 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); | ||||
|  | ||||
|         private int _disconnected; | ||||
|         public bool MarkDisconnected() => Interlocked.Exchange(ref _disconnected, 1) == 0; | ||||
|     } | ||||
| } | ||||
| @@ -11,6 +11,9 @@ using ErrorEventArgs = EonaCat.Connections.EventArguments.ErrorEventArgs; | ||||
|  | ||||
| namespace EonaCat.Connections | ||||
| { | ||||
|     // This file is part of the EonaCat project(s) which is released under the Apache License. | ||||
|     // See the LICENSE file or go to https://EonaCat.com/license for full license details. | ||||
|  | ||||
|     public class NetworkClient : IDisposable | ||||
|     { | ||||
|         private readonly Configuration _config; | ||||
| @@ -21,27 +24,20 @@ namespace EonaCat.Connections | ||||
|         private CancellationTokenSource _cancellation; | ||||
|         private bool _isConnected; | ||||
|  | ||||
|         private readonly object _stateLock = new object(); | ||||
|         private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); | ||||
|         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 HashSet<string> _joinedRooms = new(); | ||||
|         private readonly SemaphoreSlim _sendLock = new(1, 1); | ||||
|         private readonly SemaphoreSlim _connectLock = new(1, 1); | ||||
|         private readonly SemaphoreSlim _readLock = new(1, 1); | ||||
|  | ||||
|         public bool IsConnected | ||||
|         { | ||||
|             get { lock (_stateLock) | ||||
|                 { | ||||
|                     return _isConnected; | ||||
|                 } | ||||
|             } | ||||
|             private set { lock (_stateLock) | ||||
|                 { | ||||
|                     _isConnected = value; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public bool IsAutoReconnecting { get; private set; } | ||||
|         public DateTime ConnectionTime { get; private set; } | ||||
|         public DateTime StartTime { get; set; } | ||||
|         public TimeSpan Uptime => DateTime.UtcNow - ConnectionTime; | ||||
|  | ||||
|         private bool _disposed; | ||||
|         public event EventHandler<ConnectionEventArgs> OnConnected; | ||||
|         public event EventHandler<DataReceivedEventArgs> OnDataReceived; | ||||
|         public event EventHandler<ConnectionEventArgs> OnDisconnected; | ||||
| @@ -49,120 +45,172 @@ namespace EonaCat.Connections | ||||
|         public event EventHandler<ErrorEventArgs> OnEncryptionError; | ||||
|         public event EventHandler<ErrorEventArgs> OnGeneralError; | ||||
|  | ||||
|         public string IpAddress => _config?.Host ?? string.Empty; | ||||
|         public int Port => _config?.Port ?? 0; | ||||
|         private readonly List<IClientPlugin> _plugins = new(); | ||||
|  | ||||
|         public NetworkClient(Configuration config) => _config = config; | ||||
|         public NetworkClient(Configuration config) | ||||
|         { | ||||
|             _config = config ?? throw new ArgumentNullException(nameof(config)); | ||||
|         } | ||||
|  | ||||
|         public async Task ConnectAsync() | ||||
|         { | ||||
|             lock (_stateLock) | ||||
|             await _connectLock.WaitAsync(); | ||||
|             try | ||||
|             { | ||||
|                 _cancellation?.Cancel(); | ||||
|                 _cancellation = new CancellationTokenSource(); | ||||
|             } | ||||
|  | ||||
|             if (_config.Protocol == ProtocolType.TCP) | ||||
|             { | ||||
|                 await ConnectTcpAsync(); | ||||
|                 if (_config.Protocol == ProtocolType.TCP) | ||||
|                 { | ||||
|                     await ConnectTcpAsync(); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     await ConnectUdpAsync(); | ||||
|                 } | ||||
|             } | ||||
|             else | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 await ConnectUdpAsync(); | ||||
|                 OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Connection error" }); | ||||
|                 NotifyError(ex, "General error"); | ||||
|                 if (_config.EnableAutoReconnect) | ||||
|                 { | ||||
|                     _ = Task.Run(() => AutoReconnectAsync()); | ||||
|                 } | ||||
|             } | ||||
|             finally | ||||
|             { | ||||
|                 _connectLock.Release(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task ConnectTcpAsync() | ||||
|         { | ||||
|             try | ||||
|             _tcpClient = new TcpClient(); | ||||
|             await _tcpClient.ConnectAsync(_config.Host, _config.Port); | ||||
|  | ||||
|             Stream stream = _tcpClient.GetStream(); | ||||
|  | ||||
|             // Setup SSL if required | ||||
|             if (_config.UseSsl) | ||||
|             { | ||||
|                 var client = new TcpClient(); | ||||
|                 await client.ConnectAsync(_config.Host, _config.Port); | ||||
|  | ||||
|                 Stream stream = client.GetStream(); | ||||
|  | ||||
|                 if (_config.UseSsl) | ||||
|                 try | ||||
|                 { | ||||
|                     try | ||||
|                     var sslStream = new SslStream(stream, false, userCertificateValidationCallback: _config.GetRemoteCertificateValidationCallback()); | ||||
|                     if (_config.Certificate != null) | ||||
|                     { | ||||
|                         var sslStream = new SslStream(stream, false, _config.GetRemoteCertificateValidationCallback()); | ||||
|                         if (_config.Certificate != null) | ||||
|                         { | ||||
|                             sslStream.AuthenticateAsClient(_config.Host, new X509CertificateCollection { _config.Certificate }, _config.CheckCertificateRevocation); | ||||
|                         } | ||||
|                         else | ||||
|                         { | ||||
|                             sslStream.AuthenticateAsClient(_config.Host); | ||||
|                         } | ||||
|  | ||||
|                         stream = sslStream; | ||||
|                         await sslStream.AuthenticateAsClientAsync( | ||||
|                             _config.Host, | ||||
|                             new X509CertificateCollection { _config.Certificate }, | ||||
|                             _config.CheckCertificateRevocation | ||||
|                         ); | ||||
|                     } | ||||
|                     catch (Exception ex) | ||||
|                     else | ||||
|                     { | ||||
|                         OnSslError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "SSL authentication failed" }); | ||||
|                         return; | ||||
|                         await sslStream.AuthenticateAsClientAsync(_config.Host); | ||||
|                     } | ||||
|                     stream = sslStream; | ||||
|                 } | ||||
|  | ||||
|                 if (_config.UseAesEncryption) | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     try | ||||
|                     { | ||||
|                         _aesEncryption = await AesKeyExchange.ReceiveAesKeyAsync(stream, _config.AesPassword); | ||||
|                     } | ||||
|                     catch (Exception ex) | ||||
|                     { | ||||
|                         OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "AES setup failed" }); | ||||
|                         return; | ||||
|                     } | ||||
|                     OnSslError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "SSL authentication failed" }); | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 lock (_stateLock) | ||||
|                 { | ||||
|                     _tcpClient = client; | ||||
|                     _stream = stream; | ||||
|                     IsConnected = true; | ||||
|                 } | ||||
|  | ||||
|                 OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); | ||||
|  | ||||
|                 _ = Task.Run(() => ReceiveDataAsync(_cancellation.Token), _cancellation.Token); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|  | ||||
|             // Setup AES encryption if required | ||||
|             if (_config.UseAesEncryption) | ||||
|             { | ||||
|                 IsConnected = false; | ||||
|                 OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Failed to connect" }); | ||||
|                 _ = Task.Run(() => AutoReconnectAsync()); | ||||
|                 try | ||||
|                 { | ||||
|                     _aesEncryption = await AesKeyExchange.ReceiveAesKeyAsync(stream, _config.AesPassword); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "AES setup failed" }); | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             _stream = stream; | ||||
|             _isConnected = true; | ||||
|             ConnectionTime = DateTime.UtcNow; | ||||
|             OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); | ||||
|             NotifyConnected(); | ||||
|  | ||||
|             // Start receiving data | ||||
|             _ = Task.Run(() => ReceiveDataAsync(), _cancellation.Token); | ||||
|         } | ||||
|  | ||||
|         public void RegisterPlugin(IClientPlugin plugin) | ||||
|         { | ||||
|             if (_plugins.Any(p => p.Name == plugin.Name)) | ||||
|                 return; | ||||
|  | ||||
|             _plugins.Add(plugin); | ||||
|             plugin.OnClientStarted(this); | ||||
|         } | ||||
|  | ||||
|         public void UnregisterPlugin(IClientPlugin plugin) | ||||
|         { | ||||
|             if (_plugins.Remove(plugin)) | ||||
|             { | ||||
|                 plugin.OnClientStopped(this); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private void NotifyConnected() | ||||
|         { | ||||
|             foreach (var plugin in _plugins) | ||||
|             { | ||||
|                 plugin.OnClientConnected(this); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private void NotifyDisconnected(DisconnectReason reason, Exception exception) | ||||
|         { | ||||
|             foreach (var plugin in _plugins) | ||||
|             { | ||||
|                 plugin.OnClientDisconnected(this, reason, exception); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private void NotifyData(byte[] data, string stringData, bool isBinary) | ||||
|         { | ||||
|             foreach (var plugin in _plugins) | ||||
|             { | ||||
|                 plugin.OnDataReceived(this, data, stringData, isBinary); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private void NotifyError(Exception ex, string message) | ||||
|         { | ||||
|             foreach (var plugin in _plugins) | ||||
|             { | ||||
|                 plugin.OnError(this, ex, message); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public string IpAddress => _config != null ? _config.Host : string.Empty; | ||||
|         public int Port => _config != null ? _config.Port : 0; | ||||
|  | ||||
|         public bool IsAutoReconnectRunning { get; private set; } | ||||
|  | ||||
|         private async Task ConnectUdpAsync() | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var client = new UdpClient(); | ||||
|                 client.Connect(_config.Host, _config.Port); | ||||
|             _udpClient = new UdpClient(); | ||||
|             _udpClient.Connect(_config.Host, _config.Port); | ||||
|             _isConnected = true; | ||||
|             ConnectionTime = DateTime.UtcNow; | ||||
|             OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); | ||||
|  | ||||
|                 lock (_stateLock) | ||||
|                 { | ||||
|                     _udpClient = client; | ||||
|                     IsConnected = true; | ||||
|                 } | ||||
|  | ||||
|                 OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); | ||||
|  | ||||
|                 _ = Task.Run(() => ReceiveUdpDataAsync(_cancellation.Token), _cancellation.Token); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 IsConnected = false; | ||||
|                 OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Failed to connect UDP" }); | ||||
|             } | ||||
|             // Start receiving data | ||||
|             _ = Task.Run(() => ReceiveUdpDataAsync(), _cancellation.Token); | ||||
|             await Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         private async Task ReceiveDataAsync(CancellationToken ct) | ||||
|         private async Task ReceiveDataAsync() | ||||
|         { | ||||
|             while (!ct.IsCancellationRequested && IsConnected) | ||||
|             while (!_cancellation.Token.IsCancellationRequested && _isConnected) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
| @@ -171,7 +219,8 @@ namespace EonaCat.Connections | ||||
|                     if (_config.UseAesEncryption && _aesEncryption != null) | ||||
|                     { | ||||
|                         var lengthBuffer = new byte[4]; | ||||
|                         if (await ReadExactAsync(_stream, lengthBuffer, 4, ct) == 0) | ||||
|                         int read = await ReadExactAsync(_stream, lengthBuffer, 4, _cancellation.Token).ConfigureAwait(false); | ||||
|                         if (read == 0) | ||||
|                         { | ||||
|                             break; | ||||
|                         } | ||||
| @@ -182,19 +231,33 @@ namespace EonaCat.Connections | ||||
|                         } | ||||
|  | ||||
|                         int length = BitConverter.ToInt32(lengthBuffer, 0); | ||||
|                         if (length <= 0) | ||||
|                         { | ||||
|                             throw new InvalidDataException("Invalid packet length"); | ||||
|                         } | ||||
|  | ||||
|                         var encrypted = new byte[length]; | ||||
|                         await ReadExactAsync(_stream, encrypted, length, ct); | ||||
|  | ||||
|                         data = await AesCryptoHelpers.DecryptDataAsync(encrypted, _aesEncryption); | ||||
|                         await ReadExactAsync(_stream, encrypted, length, _cancellation.Token).ConfigureAwait(false); | ||||
|                         data = await AesKeyExchange.DecryptDataAsync(encrypted, _aesEncryption).ConfigureAwait(false); | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         data = new byte[_config.BufferSize]; | ||||
|                         int bytesRead = await _stream.ReadAsync(data, 0, data.Length, ct); | ||||
|                         int bytesRead; | ||||
|                         await _readLock.WaitAsync(_cancellation.Token); | ||||
|                         try | ||||
|                         { | ||||
|                             bytesRead = await _stream.ReadAsync(data, 0, data.Length, _cancellation.Token); | ||||
|                         } | ||||
|                         finally | ||||
|                         { | ||||
|                             _readLock.Release(); | ||||
|                         } | ||||
|  | ||||
|                         if (bytesRead == 0) | ||||
|                         { | ||||
|                             break; | ||||
|                             await DisconnectAsync(DisconnectReason.RemoteClosed); | ||||
|                             return; | ||||
|                         } | ||||
|  | ||||
|                         if (bytesRead < data.Length) | ||||
| @@ -207,12 +270,21 @@ namespace EonaCat.Connections | ||||
|  | ||||
|                     await ProcessReceivedDataAsync(data); | ||||
|                 } | ||||
|                 catch (IOException ioEx) | ||||
|                 { | ||||
|                     await DisconnectAsync(DisconnectReason.RemoteClosed, ioEx); | ||||
|                 } | ||||
|                 catch (SocketException sockEx) | ||||
|                 { | ||||
|                     await DisconnectAsync(DisconnectReason.Error, sockEx); | ||||
|                 } | ||||
|                 catch (OperationCanceledException) | ||||
|                 { | ||||
|                     await DisconnectAsync(DisconnectReason.Timeout); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     IsConnected = false; | ||||
|                     OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving data" }); | ||||
|                     _ = Task.Run(() => AutoReconnectAsync()); | ||||
|                     break; | ||||
|                     await DisconnectAsync(DisconnectReason.Error, ex); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
| @@ -222,22 +294,29 @@ namespace EonaCat.Connections | ||||
|         private async Task<int> ReadExactAsync(Stream stream, byte[] buffer, int length, CancellationToken ct) | ||||
|         { | ||||
|             int offset = 0; | ||||
|             while (offset < length) | ||||
|             await _readLock.WaitAsync(ct); | ||||
|             try | ||||
|             { | ||||
|                 int read = await stream.ReadAsync(buffer, offset, length - offset, ct); | ||||
|                 if (read == 0) | ||||
|                 while (offset < length) | ||||
|                 { | ||||
|                     return 0; | ||||
|                     int read = await stream.ReadAsync(buffer, offset, length - offset, ct); | ||||
|                     if (read == 0) | ||||
|                     { | ||||
|                         return 0; | ||||
|                     } | ||||
|                     offset += read; | ||||
|                 } | ||||
|  | ||||
|                 offset += read; | ||||
|                 return offset; | ||||
|             } | ||||
|             finally | ||||
|             { | ||||
|                 _readLock.Release(); | ||||
|             } | ||||
|             return offset; | ||||
|         } | ||||
|  | ||||
|         private async Task ReceiveUdpDataAsync(CancellationToken ct) | ||||
|         private async Task ReceiveUdpDataAsync() | ||||
|         { | ||||
|             while (!ct.IsCancellationRequested && IsConnected) | ||||
|             while (!_cancellation.Token.IsCancellationRequested && _isConnected) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
| @@ -246,8 +325,10 @@ namespace EonaCat.Connections | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving UDP data" }); | ||||
|                     IsConnected = false; | ||||
|                     OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving data" }); | ||||
|                     NotifyError(ex, "General error"); | ||||
|                     _isConnected = false; | ||||
|                     ConnectionTime = DateTime.MinValue; | ||||
|                     _ = Task.Run(() => AutoReconnectAsync()); | ||||
|                     break; | ||||
|                 } | ||||
| @@ -258,15 +339,27 @@ namespace EonaCat.Connections | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 string stringData = null; | ||||
|                 bool isBinary = true; | ||||
|                 string stringData = null; | ||||
|  | ||||
|                 try | ||||
|                 { | ||||
|                     stringData = Encoding.UTF8.GetString(data); | ||||
|                     isBinary = Encoding.UTF8.GetBytes(stringData).Length != data.Length; | ||||
|                     if (Encoding.UTF8.GetBytes(stringData).Length == data.Length) | ||||
|                     { | ||||
|                         isBinary = false; | ||||
|                     } | ||||
|                 } | ||||
|                 catch | ||||
|                 { | ||||
|                     // Keep as binary | ||||
|                 } | ||||
|  | ||||
|                 if (!isBinary && stringData != null && stringData.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) | ||||
|                 { | ||||
|                     await DisconnectAsync(DisconnectReason.RemoteClosed); | ||||
|                     return; | ||||
|                 } | ||||
|                 catch { } | ||||
|  | ||||
|                 OnDataReceived?.Invoke(this, new DataReceivedEventArgs | ||||
|                 { | ||||
| @@ -275,17 +368,25 @@ namespace EonaCat.Connections | ||||
|                     StringData = stringData, | ||||
|                     IsBinary = isBinary | ||||
|                 }); | ||||
|                 NotifyData(data, stringData, isBinary); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 var handler = _config.UseAesEncryption ? OnEncryptionError : OnGeneralError; | ||||
|                 handler?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error processing data" }); | ||||
|                 if (_config.UseAesEncryption) | ||||
|                 { | ||||
|                     OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error processing data" }); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error processing data" }); | ||||
|                     NotifyError(ex, "General error"); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task SendAsync(byte[] data) | ||||
|         { | ||||
|             if (!IsConnected) | ||||
|             if (!_isConnected) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
| @@ -295,7 +396,7 @@ namespace EonaCat.Connections | ||||
|             { | ||||
|                 if (_config.UseAesEncryption && _aesEncryption != null) | ||||
|                 { | ||||
|                     data = await AesCryptoHelpers.EncryptDataAsync(data, _aesEncryption); | ||||
|                     data = await AesKeyExchange.EncryptDataAsync(data, _aesEncryption); | ||||
|  | ||||
|                     var lengthPrefix = BitConverter.GetBytes(data.Length); | ||||
|                     if (BitConverter.IsLittleEndian) | ||||
| @@ -321,8 +422,15 @@ namespace EonaCat.Connections | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 var handler = _config.UseAesEncryption ? OnEncryptionError : OnGeneralError; | ||||
|                 handler?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error sending data" }); | ||||
|                 if (_config.UseAesEncryption) | ||||
|                 { | ||||
|                     OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error encrypting/sending data" }); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error sending data" }); | ||||
|                     NotifyError(ex, "General error"); | ||||
|                 } | ||||
|             } | ||||
|             finally | ||||
|             { | ||||
| @@ -330,121 +438,140 @@ namespace EonaCat.Connections | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /// <summary>Join a room (server should recognize this command)</summary> | ||||
|         public async Task JoinRoomAsync(string roomName) | ||||
|         public async Task SendAsync(string message) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(roomName) || _joinedRooms.Contains(roomName)) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             _joinedRooms.Add(roomName); | ||||
|             await SendAsync($"JOIN_ROOM:{roomName}"); | ||||
|             await SendAsync(Encoding.UTF8.GetBytes(message)); | ||||
|         } | ||||
|  | ||||
|         public async Task LeaveRoomAsync(string roomName) | ||||
|         public async Task SendNicknameAsync(string nickname) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(roomName) || !_joinedRooms.Contains(roomName)) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             _joinedRooms.Remove(roomName); | ||||
|             await SendAsync($"LEAVE_ROOM:{roomName}"); | ||||
|             await SendAsync($"NICKNAME:{nickname}"); | ||||
|         } | ||||
|  | ||||
|         public async Task SendToRoomAsync(string roomName, string message) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(roomName) || !_joinedRooms.Contains(roomName)) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             await SendAsync($"ROOM_MSG:{roomName}:{message}"); | ||||
|         } | ||||
|  | ||||
|         public IReadOnlyCollection<string> GetJoinedRooms() | ||||
|         { | ||||
|             return _joinedRooms.ToList().AsReadOnly(); | ||||
|         } | ||||
|  | ||||
|         public async Task SendAsync(string message) => await SendAsync(Encoding.UTF8.GetBytes(message)); | ||||
|         private async Task SendNicknameAsync(string nickname) => await SendAsync($"NICKNAME:{nickname}"); | ||||
|  | ||||
|         private async Task AutoReconnectAsync() | ||||
|         { | ||||
|             if (!_config.EnableAutoReconnect || IsAutoReconnecting) | ||||
|             if (!_config.EnableAutoReconnect) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (IsAutoReconnectRunning) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             int attempt = 0; | ||||
|             IsAutoReconnecting = true; | ||||
|  | ||||
|             while (!IsConnected && (_config.MaxReconnectAttempts == 0 || attempt < _config.MaxReconnectAttempts)) | ||||
|             while (_config.EnableAutoReconnect && !_isConnected && (_config.MaxReconnectAttempts == 0 || attempt < _config.MaxReconnectAttempts)) | ||||
|             { | ||||
|                 attempt++; | ||||
|  | ||||
|                 try | ||||
|                 { | ||||
|                     OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnecting attempt {attempt}" }); | ||||
|                     OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Attempting to reconnect (Attempt {attempt})" }); | ||||
|                     IsAutoReconnectRunning = true; | ||||
|                     await ConnectAsync(); | ||||
|                     if (IsConnected) | ||||
|  | ||||
|                     if (_isConnected) | ||||
|                     { | ||||
|                         OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnected after {attempt} attempt(s)" }); | ||||
|                         OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnected successfully after {attempt} attempt(s)" }); | ||||
|                         IsAutoReconnectRunning = false; | ||||
|                         break; | ||||
|                     } | ||||
|                 } | ||||
|                 catch { } | ||||
|                 catch | ||||
|                 { | ||||
|                     // Do nothing | ||||
|                 } | ||||
|  | ||||
|                 await Task.Delay(_config.ReconnectDelayMs); | ||||
|             } | ||||
|  | ||||
|             if (!IsConnected) | ||||
|             if (!_isConnected) | ||||
|             { | ||||
|                 OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = "Failed to reconnect" }); | ||||
|             } | ||||
|  | ||||
|             IsAutoReconnecting = false; | ||||
|         } | ||||
|  | ||||
|         private string _nickname; | ||||
|         public async Task SetNicknameAsync(string nickname) | ||||
|         public async Task DisconnectAsync( | ||||
|     DisconnectReason reason = DisconnectReason.LocalClosed, | ||||
|     Exception exception = null, | ||||
|     bool forceDisconnection = false) | ||||
|         { | ||||
|             _nickname = nickname; | ||||
|             await SendNicknameAsync(nickname); | ||||
|         } | ||||
|  | ||||
|         public string Nickname => _nickname; | ||||
|  | ||||
|  | ||||
|         public async Task DisconnectAsync() | ||||
|         { | ||||
|             lock (_stateLock) | ||||
|             await _connectLock.WaitAsync(); | ||||
|             try | ||||
|             { | ||||
|                 if (!IsConnected) | ||||
|                 if (!_isConnected) | ||||
|                 { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 IsConnected = false; | ||||
|                 _isConnected = false; | ||||
|                 ConnectionTime = DateTime.MinValue; | ||||
|  | ||||
|                 _cancellation?.Cancel(); | ||||
|                 _tcpClient?.Close(); | ||||
|                 _udpClient?.Close(); | ||||
|                 _stream?.Dispose(); | ||||
|                 _aesEncryption?.Dispose(); | ||||
|  | ||||
|                 OnDisconnected?.Invoke(this, new ConnectionEventArgs | ||||
|                 { | ||||
|                     ClientId = "self", | ||||
|                     RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port), | ||||
|                     Reason = ConnectionEventArgs.Determine(reason, exception), | ||||
|                     Exception = exception | ||||
|                 }); | ||||
|                 NotifyDisconnected(reason, exception); | ||||
|  | ||||
|                 if (!forceDisconnection && reason != DisconnectReason.Forced) | ||||
|                 { | ||||
|                     _ = Task.Run(() => AutoReconnectAsync()); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     Console.WriteLine("Auto-reconnect disabled due to forced disconnection."); | ||||
|                     _config.EnableAutoReconnect = false; | ||||
|                 } | ||||
|             } | ||||
|             finally | ||||
|             { | ||||
|                 _connectLock.Release(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|  | ||||
|         public async ValueTask DisposeAsync() | ||||
|         { | ||||
|             if (_disposed) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             _tcpClient?.Close(); | ||||
|             _udpClient?.Close(); | ||||
|             _stream?.Dispose(); | ||||
|             _aesEncryption?.Dispose(); | ||||
|             _joinedRooms?.Clear(); | ||||
|              | ||||
|             OnDisconnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self" }); | ||||
|             _disposed = true; | ||||
|  | ||||
|             await DisconnectAsync(forceDisconnection: true); | ||||
|  | ||||
|             foreach (var plugin in _plugins.ToList()) | ||||
|             { | ||||
|                 plugin.OnClientStopped(this); | ||||
|             } | ||||
|  | ||||
|             _cancellation?.Dispose(); | ||||
|             _sendLock.Dispose(); | ||||
|             _connectLock.Dispose(); | ||||
|             _readLock.Dispose(); | ||||
|         } | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|             _cancellation?.Cancel(); | ||||
|             DisconnectAsync().Wait(); | ||||
|             _cancellation?.Dispose(); | ||||
|             _sendLock.Dispose(); | ||||
|             if (_disposed) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             _disposed = true; | ||||
|             DisposeAsync().AsTask().GetAwaiter().GetResult(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										112
									
								
								EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | ||||
| using EonaCat.Json; | ||||
| using System.Net; | ||||
|  | ||||
| namespace EonaCat.Connections.Plugins.Client | ||||
| { | ||||
|     // This file is part of the EonaCat project(s) which is released under the Apache License. | ||||
|     // See the LICENSE file or go to https://EonaCat.com/license for full license details. | ||||
|  | ||||
|     public class ClientHttpMetricsPlugin : IClientPlugin | ||||
|     { | ||||
|         public string Name => "ClientMetricsPlugin"; | ||||
|  | ||||
|         private NetworkClient _client; | ||||
|         private long _bytesSent; | ||||
|         private long _bytesReceived; | ||||
|         private long _messagesSent; | ||||
|         private long _messagesReceived; | ||||
|  | ||||
|         private readonly int _httpPort; | ||||
|         private HttpListener _httpListener; | ||||
|         private CancellationTokenSource _cts; | ||||
|  | ||||
|         public ClientHttpMetricsPlugin(int httpPort = 8080) | ||||
|         { | ||||
|             _httpPort = httpPort; | ||||
|         } | ||||
|  | ||||
|         public void OnClientStarted(NetworkClient client) | ||||
|         { | ||||
|             _client = client; | ||||
|             _cts = new CancellationTokenSource(); | ||||
|             StartHttpServer(_cts.Token); | ||||
|         } | ||||
|  | ||||
|         public void OnClientConnected(NetworkClient client) | ||||
|         { | ||||
|             Console.WriteLine($"[{Name}] Connected to server at {client.IpAddress}:{client.Port}"); | ||||
|         } | ||||
|  | ||||
|         public void OnClientDisconnected(NetworkClient client, DisconnectReason reason, Exception exception) | ||||
|         { | ||||
|             Console.WriteLine($"[{Name}] Disconnected: {reason} {exception?.Message}"); | ||||
|         } | ||||
|  | ||||
|         public void OnDataReceived(NetworkClient client, byte[] data, string stringData, bool isBinary) | ||||
|         { | ||||
|             _bytesReceived += data.Length; | ||||
|             _messagesReceived++; | ||||
|         } | ||||
|  | ||||
|         public void OnError(NetworkClient client, Exception exception, string message) | ||||
|         { | ||||
|             Console.WriteLine($"[{Name}] Error: {message} - {exception?.Message}"); | ||||
|         } | ||||
|  | ||||
|         public void OnClientStopped(NetworkClient client) | ||||
|         { | ||||
|             _cts.Cancel(); | ||||
|             _httpListener?.Stop(); | ||||
|             Console.WriteLine($"[{Name}] Plugin stopped."); | ||||
|         } | ||||
|  | ||||
|         public void IncrementSent(byte[] data) | ||||
|         { | ||||
|             _bytesSent += data.Length; | ||||
|             _messagesSent++; | ||||
|         } | ||||
|  | ||||
|         private void StartHttpServer(CancellationToken token) | ||||
|         { | ||||
|             _httpListener = new HttpListener(); | ||||
|             _httpListener.Prefixes.Add($"http://*:{_httpPort}/metrics/"); | ||||
|             _httpListener.Start(); | ||||
|  | ||||
|             Task.Run(async () => | ||||
|             { | ||||
|                 while (!token.IsCancellationRequested) | ||||
|                 { | ||||
|                     try | ||||
|                     { | ||||
|                         var context = await _httpListener.GetContextAsync(); | ||||
|                         var response = context.Response; | ||||
|  | ||||
|                         var metrics = new | ||||
|                         { | ||||
|                             IsConnected = _client.IsConnected, | ||||
|                             Ip = _client.IpAddress, | ||||
|                             Port = _client.Port, | ||||
|                             Uptime = _client.Uptime.TotalSeconds, | ||||
|                             BytesSent = _bytesSent, | ||||
|                             BytesReceived = _bytesReceived, | ||||
|                             MessagesSent = _messagesSent, | ||||
|                             MessagesReceived = _messagesReceived | ||||
|                         }; | ||||
|  | ||||
|                         var json = JsonHelper.ToJson(metrics, Formatting.Indented); | ||||
|                         var buffer = System.Text.Encoding.UTF8.GetBytes(json); | ||||
|  | ||||
|                         response.ContentType = "application/json"; | ||||
|                         response.ContentLength64 = buffer.Length; | ||||
|                         await response.OutputStream.WriteAsync(buffer, 0, buffer.Length, token); | ||||
|                         response.Close(); | ||||
|                     } | ||||
|                     catch (Exception) | ||||
|                     { | ||||
|                         // ignore | ||||
|                     } | ||||
|                 } | ||||
|             }, token); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										106
									
								
								EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | ||||
| using EonaCat.Connections.Models; | ||||
| using EonaCat.Json; | ||||
| using System.Net; | ||||
| using System.Text; | ||||
|  | ||||
| namespace EonaCat.Connections.Plugins.Server | ||||
| { | ||||
|     // This file is part of the EonaCat project(s) which is released under the Apache License. | ||||
|     // See the LICENSE file or go to https://EonaCat.com/license for full license details. | ||||
|  | ||||
|     public class HttpMetricsPlugin : IServerPlugin | ||||
|     { | ||||
|         public string Name => "HttpMetricsPlugin"; | ||||
|  | ||||
|         private readonly int _port; | ||||
|         private HttpListener _httpListener; | ||||
|         private CancellationTokenSource _cts; | ||||
|         private NetworkServer _server; | ||||
|  | ||||
|         public HttpMetricsPlugin(int port = 9100) | ||||
|         { | ||||
|             _port = port; | ||||
|         } | ||||
|  | ||||
|         public void OnServerStarted(NetworkServer server) | ||||
|         { | ||||
|             _server = server; | ||||
|             _cts = new CancellationTokenSource(); | ||||
|             _httpListener = new HttpListener(); | ||||
|             _httpListener.Prefixes.Add($"http://*:{_port}/metrics/"); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 _httpListener.Start(); | ||||
|                 Console.WriteLine($"[{Name}] Metrics endpoint running at http://localhost:{_port}/metrics/"); | ||||
|             } | ||||
|             catch (HttpListenerException ex) | ||||
|             { | ||||
|                 Console.WriteLine($"[{Name}] Failed to start HTTP listener: {ex.Message}"); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             Task.Run(async () => | ||||
|             { | ||||
|                 while (!_cts.IsCancellationRequested) | ||||
|                 { | ||||
|                     try | ||||
|                     { | ||||
|                         var context = await _httpListener.GetContextAsync(); | ||||
|  | ||||
|                         if (context.Request.Url.AbsolutePath == "/metrics") | ||||
|                         { | ||||
|                             var stats = _server.GetStats(); | ||||
|  | ||||
|                             var responseObj = new | ||||
|                             { | ||||
|                                 uptime = stats.Uptime.ToString(), | ||||
|                                 startTime = stats.StartTime, | ||||
|                                 activeConnections = stats.ActiveConnections, | ||||
|                                 totalConnections = stats.TotalConnections, | ||||
|                                 bytesSent = stats.BytesSent, | ||||
|                                 bytesReceived = stats.BytesReceived, | ||||
|                                 messagesSent = stats.MessagesSent, | ||||
|                                 messagesReceived = stats.MessagesReceived, | ||||
|                                 messagesPerSecond = stats.MessagesPerSecond | ||||
|                             }; | ||||
|  | ||||
|                             var json = JsonHelper.ToJson(responseObj, Formatting.Indented); | ||||
|                             var buffer = Encoding.UTF8.GetBytes(json); | ||||
|  | ||||
|                             context.Response.ContentType = "application/json"; | ||||
|                             context.Response.StatusCode = 200; | ||||
|                             await context.Response.OutputStream.WriteAsync(buffer, 0, buffer.Length); | ||||
|                             context.Response.OutputStream.Close(); | ||||
|                         } | ||||
|                         else | ||||
|                         { | ||||
|                             context.Response.StatusCode = 404; | ||||
|                             context.Response.Close(); | ||||
|                         } | ||||
|                     } | ||||
|                     catch (ObjectDisposedException) { } | ||||
|                     catch (HttpListenerException) { } | ||||
|                     catch (Exception ex) | ||||
|                     { | ||||
|                         Console.WriteLine($"[{Name}] Error: {ex}"); | ||||
|                     } | ||||
|                 } | ||||
|             }, _cts.Token); | ||||
|         } | ||||
|  | ||||
|         public void OnServerStopped(NetworkServer server) | ||||
|         { | ||||
|             _cts?.Cancel(); | ||||
|             if (_httpListener != null && _httpListener.IsListening) | ||||
|             { | ||||
|                 _httpListener.Stop(); | ||||
|                 _httpListener.Close(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public void OnClientConnected(Connection client) { } | ||||
|         public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) { } | ||||
|         public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) { } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										53
									
								
								EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| using EonaCat.Connections.Models; | ||||
|  | ||||
| namespace EonaCat.Connections.Plugins.Server | ||||
| { | ||||
|     // This file is part of the EonaCat project(s) which is released under the Apache License. | ||||
|     // See the LICENSE file or go to https://EonaCat.com/license for full license details. | ||||
|  | ||||
|     public class IdleTimeoutPlugin : IServerPlugin | ||||
|     { | ||||
|         public string Name => "IdleTimeoutPlugin"; | ||||
|  | ||||
|         private readonly TimeSpan _timeout; | ||||
|         private CancellationTokenSource _cts; | ||||
|  | ||||
|         public IdleTimeoutPlugin(TimeSpan timeout) | ||||
|         { | ||||
|             _timeout = timeout; | ||||
|         } | ||||
|  | ||||
|         public void OnServerStarted(NetworkServer server) | ||||
|         { | ||||
|             _cts = new CancellationTokenSource(); | ||||
|  | ||||
|             // Background task to check idle clients | ||||
|             Task.Run(async () => | ||||
|             { | ||||
|                 while (!_cts.IsCancellationRequested) | ||||
|                 { | ||||
|                     foreach (var kvp in server.GetClients()) | ||||
|                     { | ||||
|                         var client = kvp.Value; | ||||
|                         if (DateTime.UtcNow - client.LastActive > _timeout) | ||||
|                         { | ||||
|                             Console.WriteLine($"[{Name}] Disconnecting idle client {client.RemoteEndPoint}"); | ||||
|                             _ = server.DisconnectClientAsync(client.Id, DisconnectReason.Timeout); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     await Task.Delay(5000, _cts.Token); // Check every 5s | ||||
|                 } | ||||
|             }, _cts.Token); | ||||
|         } | ||||
|  | ||||
|         public void OnServerStopped(NetworkServer server) | ||||
|         { | ||||
|             _cts?.Cancel(); | ||||
|         } | ||||
|  | ||||
|         public void OnClientConnected(Connection client) { } | ||||
|         public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) { } | ||||
|         public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) { } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										65
									
								
								EonaCat.Connections/Plugins/Server/MetricsPlugin.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								EonaCat.Connections/Plugins/Server/MetricsPlugin.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| using EonaCat.Connections.Models; | ||||
|  | ||||
| namespace EonaCat.Connections.Plugins.Server | ||||
| { | ||||
|     // This file is part of the EonaCat project(s) which is released under the Apache License. | ||||
|     // See the LICENSE file or go to https://EonaCat.com/license for full license details. | ||||
|  | ||||
|     public class MetricsPlugin : IServerPlugin | ||||
|     { | ||||
|         public string Name => "MetricsPlugin"; | ||||
|  | ||||
|         private readonly TimeSpan _interval; | ||||
|         private CancellationTokenSource _cts; | ||||
|         private NetworkServer _server; | ||||
|  | ||||
|         public MetricsPlugin(TimeSpan interval) | ||||
|         { | ||||
|             _interval = interval; | ||||
|         } | ||||
|  | ||||
|         public void OnServerStarted(NetworkServer server) | ||||
|         { | ||||
|             _server = server; | ||||
|             _cts = new CancellationTokenSource(); | ||||
|  | ||||
|             Task.Run(async () => | ||||
|             { | ||||
|                 while (!_cts.IsCancellationRequested) | ||||
|                 { | ||||
|                     try | ||||
|                     { | ||||
|                         var stats = server.GetStats(); | ||||
|  | ||||
|                         Console.WriteLine( | ||||
|                             $"[{Name}] Uptime: {stats.Uptime:g} | " + | ||||
|                             $"Active: {stats.ActiveConnections} | " + | ||||
|                             $"Total: {stats.TotalConnections} | " + | ||||
|                             $"Msgs In: {stats.MessagesReceived} | " + | ||||
|                             $"Msgs Out: {stats.MessagesSent} | " + | ||||
|                             $"Bytes In: {stats.BytesReceived} | " + | ||||
|                             $"Bytes Out: {stats.BytesSent} | " + | ||||
|                             $"Msg/s: {stats.MessagesPerSecond:F2}" | ||||
|                         ); | ||||
|  | ||||
|                         await Task.Delay(_interval, _cts.Token); | ||||
|                     } | ||||
|                     catch (TaskCanceledException) { } | ||||
|                     catch (Exception ex) | ||||
|                     { | ||||
|                         Console.WriteLine($"[{Name}] Error logging metrics: {ex}"); | ||||
|                     } | ||||
|                 } | ||||
|             }, _cts.Token); | ||||
|         } | ||||
|  | ||||
|         public void OnServerStopped(NetworkServer server) | ||||
|         { | ||||
|             _cts?.Cancel(); | ||||
|         } | ||||
|  | ||||
|         public void OnClientConnected(Connection client) { } | ||||
|         public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) { } | ||||
|         public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) { } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										57
									
								
								EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| using EonaCat.Connections.Models; | ||||
| using System.Collections.Concurrent; | ||||
|  | ||||
| namespace EonaCat.Connections.Plugins.Server | ||||
| { | ||||
|     // This file is part of the EonaCat project(s) which is released under the Apache License. | ||||
|     // See the LICENSE file or go to https://EonaCat.com/license for full license details. | ||||
|  | ||||
|     public class RateLimiterPlugin : IServerPlugin | ||||
|     { | ||||
|         public string Name => "RateLimiterPlugin"; | ||||
|  | ||||
|         private readonly int _maxMessages; | ||||
|         private readonly TimeSpan _interval; | ||||
|         private readonly ConcurrentDictionary<string, ConcurrentQueue<DateTime>> _messageTimestamps; | ||||
|  | ||||
|         public RateLimiterPlugin(int maxMessages, TimeSpan interval) | ||||
|         { | ||||
|             _maxMessages = maxMessages; | ||||
|             _interval = interval; | ||||
|             _messageTimestamps = new ConcurrentDictionary<string, ConcurrentQueue<DateTime>>(); | ||||
|         } | ||||
|  | ||||
|         public void OnServerStarted(NetworkServer server) { } | ||||
|         public void OnServerStopped(NetworkServer server) { } | ||||
|  | ||||
|         public void OnClientConnected(Connection client) | ||||
|         { | ||||
|             _messageTimestamps[client.Id] = new ConcurrentQueue<DateTime>(); | ||||
|         } | ||||
|  | ||||
|         public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) | ||||
|         { | ||||
|             _messageTimestamps.TryRemove(client.Id, out _); | ||||
|         } | ||||
|  | ||||
|         public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) | ||||
|         { | ||||
|             if (!_messageTimestamps.TryGetValue(client.Id, out var queue)) return; | ||||
|  | ||||
|             var now = DateTime.UtcNow; | ||||
|             queue.Enqueue(now); | ||||
|  | ||||
|             // Remove old timestamps | ||||
|             while (queue.TryPeek(out var oldest) && now - oldest > _interval) | ||||
|                 queue.TryDequeue(out _); | ||||
|  | ||||
|             if (queue.Count > _maxMessages) | ||||
|             { | ||||
|                 Console.WriteLine($"[{Name}] Client {client.RemoteEndPoint} exceeded rate limit. Disconnecting..."); | ||||
|                  | ||||
|                 // Force disconnect | ||||
|                 client.TcpClient?.Close(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -7,6 +7,9 @@ using Timer = System.Timers.Timer; | ||||
|  | ||||
| namespace EonaCat.Connections.Processors | ||||
| { | ||||
|     // This file is part of the EonaCat project(s) which is released under the Apache License. | ||||
|     // See the LICENSE file or go to https://EonaCat.com/license for full license details. | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Processes incoming data streams into JSON or text messages per client buffer. | ||||
|     /// </summary> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user