From 0c659e9d34702fc963df02ca2eb296f79e525173 Mon Sep 17 00:00:00 2001 From: EonaCat Date: Mon, 25 Aug 2025 19:30:58 +0200 Subject: [PATCH] Made it more secure --- EonaCat.Connections.Client/Program.cs | 3 + .../Helpers/AesCryptoHelpers.cs | 105 +++++ EonaCat.Connections/Helpers/AesKeyExchange.cs | 183 +++------ EonaCat.Connections/NetworkClient.cs | 279 ++++---------- EonaCat.Connections/NetworkServer.cs | 359 +++++------------- .../Processors/JsonDataProcessor.cs | 342 +++++++++-------- 6 files changed, 533 insertions(+), 738 deletions(-) create mode 100644 EonaCat.Connections/Helpers/AesCryptoHelpers.cs diff --git a/EonaCat.Connections.Client/Program.cs b/EonaCat.Connections.Client/Program.cs index 5b79ec3..b8ec646 100644 --- a/EonaCat.Connections.Client/Program.cs +++ b/EonaCat.Connections.Client/Program.cs @@ -24,6 +24,9 @@ namespace EonaCat.Connections.Client.Example Console.Write("Enter message to send (or 'exit' to quit): "); var message = Console.ReadLine(); + HttpClient httpClient = new HttpClient(); + message = await httpClient.GetStringAsync("https://samples.json-format.com/employees/10-level/employees-10-level_100MB.json"); + if (!string.IsNullOrEmpty(message) && message.Equals("exit", StringComparison.OrdinalIgnoreCase)) { await _client.DisconnectAsync().ConfigureAwait(false); diff --git a/EonaCat.Connections/Helpers/AesCryptoHelpers.cs b/EonaCat.Connections/Helpers/AesCryptoHelpers.cs new file mode 100644 index 0000000..7fd2415 --- /dev/null +++ b/EonaCat.Connections/Helpers/AesCryptoHelpers.cs @@ -0,0 +1,105 @@ +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 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 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; + } + } + +} diff --git a/EonaCat.Connections/Helpers/AesKeyExchange.cs b/EonaCat.Connections/Helpers/AesKeyExchange.cs index 4807e65..312c980 100644 --- a/EonaCat.Connections/Helpers/AesKeyExchange.cs +++ b/EonaCat.Connections/Helpers/AesKeyExchange.cs @@ -1,5 +1,4 @@ using System.Security.Cryptography; -using System.Text; namespace EonaCat.Connections.Helpers { @@ -8,146 +7,68 @@ namespace EonaCat.Connections.Helpers public static class AesKeyExchange { - private static readonly string Pepper = "EonaCat.Connections.Salt"; + private const int SaltSize = 16; + private const int KeySize = 32; + private const int IvSize = 16; + private const int HmacSize = 32; + private const int Pbkdf2Iterations = 100_000; - /// - /// Send AES key, IV, and salt to the stream. - /// - /// - /// - /// - public static async Task SendAesKeyAsync(Stream stream, Aes aes, string password = null) + // Returns an AES object derived from the password and salt + public static async Task ReceiveAesKeyAsync(Stream stream, string password) { - var rawKey = aes.Key; - var iv = aes.IV; - var salt = new byte[32]; + // 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)) + { + key = kdf.GetBytes(KeySize); + } + + var aes = Aes.Create(); + aes.KeySize = 256; + aes.BlockSize = 128; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.Key = key; + + return aes; + } + + // Sends salt (no key) to the other side + public static async Task SendAesKeyAsync(Stream stream, Aes aes, string password) + { + // Generate random salt + byte[] salt = new byte[SaltSize]; using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(salt); } - // Send raw key, IV, and salt - await WriteBytesWithLengthAsync(stream, rawKey); - await WriteBytesWithLengthAsync(stream, iv); - await WriteBytesWithLengthAsync(stream, salt); + // Derive AES key + byte[] key; + using (var kdf = new Rfc2898DeriveBytes(password, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256)) + { + key = kdf.GetBytes(KeySize); + } + aes.Key = key; + + // Send salt only + await stream.WriteAsync(salt, 0, salt.Length); await stream.FlushAsync(); + } - // Derive key using PBKDF2-SHA256 + salt + password + pepper - if (string.IsNullOrEmpty(password)) + public static async Task ReadExactlyAsync(this Stream stream, byte[] buffer, int offset, int count) + { + int read = 0; + while (read < count) { - password = "EonaCat.Connections"; + int readBytes = await stream.ReadAsync(buffer, offset + read, count - read); + if (readBytes == 0) throw new EndOfStreamException(); + read += readBytes; } - var derivedKey = PBKDF2_SHA256(Combine(Combine(rawKey, Encoding.UTF8.GetBytes(password)), Encoding.UTF8.GetBytes(Pepper)), salt, 100_000, 32); - aes.Key = derivedKey; - - return aes; - } - - /// - /// Receive AES key, IV, and salt from the stream and derive the AES key. - /// - /// - /// - public static async Task ReceiveAesKeyAsync(Stream stream, string password = null) - { - var rawKey = await ReadBytesWithLengthAsync(stream); - var iv = await ReadBytesWithLengthAsync(stream); - var salt = await ReadBytesWithLengthAsync(stream); - - if (string.IsNullOrEmpty(password)) - { - password = "EonaCat.Connections"; - } - - // Derived key using PBKDF2-SHA256 + salt + password + pepper - var derivedKey = PBKDF2_SHA256(Combine(Combine(rawKey, Encoding.UTF8.GetBytes(password)), Encoding.UTF8.GetBytes(Pepper)), salt, 100_000, 32); - - Aes _aesEncryption = Aes.Create(); - _aesEncryption.Key = derivedKey; - _aesEncryption.IV = iv; - return _aesEncryption; - } - - private static byte[] PBKDF2_SHA256(byte[] password, byte[] salt, int iterations, int outputBytes) - { - using (var hmac = new HMACSHA256(password)) - { - int hashLength = hmac.HashSize / 8; - int keyBlocks = (int)Math.Ceiling((double)outputBytes / hashLength); - byte[] output = new byte[outputBytes]; - byte[] buffer = new byte[hashLength]; - - for (int block = 1; block <= keyBlocks; block++) - { - byte[] intBlock = BitConverter.GetBytes(block); - if (BitConverter.IsLittleEndian) - { - Array.Reverse(intBlock); - } - - hmac.Initialize(); - hmac.TransformBlock(salt, 0, salt.Length, salt, 0); - hmac.TransformFinalBlock(intBlock, 0, intBlock.Length); - Array.Copy(hmac.Hash, buffer, hashLength); - - byte[] temp = (byte[])buffer.Clone(); - for (int i = 1; i < iterations; i++) - { - temp = hmac.ComputeHash(temp); - for (int j = 0; j < hashLength; j++) - { - buffer[j] ^= temp[j]; - } - } - - int offset = (block - 1) * hashLength; - int remaining = Math.Min(hashLength, outputBytes - offset); - Array.Copy(buffer, 0, output, offset, remaining); - } - - return output; - } - } - - private static async Task ReadBytesWithLengthAsync(Stream stream) - { - var lengthBytes = new byte[4]; - await ReadExactlyAsync(stream, lengthBytes, 0, 4); - int length = BitConverter.ToInt32(lengthBytes, 0); - - var data = new byte[length]; - await ReadExactlyAsync(stream, data, 0, length); - return data; - } - - private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, int offset, int count) - { - int totalRead = 0; - while (totalRead < count) - { - int read = await stream.ReadAsync(buffer, offset + totalRead, count - totalRead); - if (read == 0) - { - throw new EndOfStreamException("Stream ended prematurely"); - } - - totalRead += read; - } - } - - private static async Task WriteBytesWithLengthAsync(Stream stream, byte[] data) - { - var lengthBytes = BitConverter.GetBytes(data.Length); - await stream.WriteAsync(lengthBytes, 0, 4); - await stream.WriteAsync(data, 0, data.Length); - } - - private static byte[] Combine(byte[] a, byte[] b) - { - var c = new byte[a.Length + b.Length]; - Buffer.BlockCopy(a, 0, c, 0, a.Length); - Buffer.BlockCopy(b, 0, c, a.Length, b.Length); - return c; } } -} \ No newline at end of file +} diff --git a/EonaCat.Connections/NetworkClient.cs b/EonaCat.Connections/NetworkClient.cs index 2ffdf14..d5b78c2 100644 --- a/EonaCat.Connections/NetworkClient.cs +++ b/EonaCat.Connections/NetworkClient.cs @@ -14,7 +14,7 @@ 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 + public class NetworkClient : IDisposable { private readonly Configuration _config; private TcpClient _tcpClient; @@ -25,6 +25,7 @@ namespace EonaCat.Connections private bool _isConnected; public bool IsConnected => _isConnected; + public bool IsAutoReconnecting { get; private set; } public event EventHandler OnConnected; public event EventHandler OnDataReceived; @@ -33,23 +34,20 @@ namespace EonaCat.Connections public event EventHandler OnEncryptionError; public event EventHandler OnGeneralError; - public NetworkClient(Configuration config) - { - _config = config; - } + public string IpAddress => _config?.Host ?? string.Empty; + public int Port => _config?.Port ?? 0; + + public NetworkClient(Configuration config) => _config = config; public async Task ConnectAsync() { + _cancellation?.Cancel(); _cancellation = new CancellationTokenSource(); if (_config.Protocol == ProtocolType.TCP) - { await ConnectTcpAsync(); - } else - { - await ConnectUdp(); - } + await ConnectUdpAsync(); } private async Task ConnectTcpAsync() @@ -61,20 +59,16 @@ namespace EonaCat.Connections Stream stream = _tcpClient.GetStream(); - // Setup SSL if required if (_config.UseSsl) { try { - var sslStream = new SslStream(stream, false, userCertificateValidationCallback: _config.GetRemoteCertificateValidationCallback()); + 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; } catch (Exception ex) @@ -84,7 +78,6 @@ namespace EonaCat.Connections } } - // Setup AES encryption if required if (_config.UseAesEncryption) { try @@ -103,49 +96,38 @@ namespace EonaCat.Connections OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); - // Start receiving data - _ = Task.Run(() => ReceiveDataAsync(), _cancellation.Token); + _ = Task.Run(() => ReceiveDataAsync(_cancellation.Token), _cancellation.Token); } catch (Exception ex) { _isConnected = false; OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Failed to connect" }); - await Task.Run(() => AutoReconnectAsync()); + _ = Task.Run(() => AutoReconnectAsync()); } } - public string IpAddress => _config != null ? _config.Host : string.Empty; - public int Port => _config != null ? _config.Port : 0; - - public bool IsAutoReconnecting { get; private set; } - - private async Task ConnectUdp() + private async Task ConnectUdpAsync() { - await Task.Run(() => + try { - try - { - _udpClient = new UdpClient(); - _udpClient.Connect(_config.Host, _config.Port); - _isConnected = true; + _udpClient = new UdpClient(); + _udpClient.Connect(_config.Host, _config.Port); + _isConnected = true; - OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); + OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); - // Start receiving data - _ = Task.Run(() => ReceiveUdpDataAsync(), _cancellation.Token); - } - catch (Exception ex) - { - _isConnected = false; - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Failed to connect UDP" }); - } - }); + _ = Task.Run(() => ReceiveUdpDataAsync(_cancellation.Token), _cancellation.Token); + } + catch (Exception ex) + { + _isConnected = false; + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Failed to connect UDP" }); + } } - - private async Task ReceiveDataAsync() + private async Task ReceiveDataAsync(CancellationToken ct) { - while (!_cancellation.Token.IsCancellationRequested && _isConnected) + while (!ct.IsCancellationRequested && _isConnected) { try { @@ -153,34 +135,22 @@ namespace EonaCat.Connections if (_config.UseAesEncryption && _aesEncryption != null) { - // Read 4-byte length prefix var lengthBuffer = new byte[4]; - int read = await ReadExactAsync(_stream, lengthBuffer, 4, _cancellation.Token); - if (read == 0) - { - break; - } - - if (BitConverter.IsLittleEndian) - { - Array.Reverse(lengthBuffer); - } + if (await ReadExactAsync(_stream, lengthBuffer, 4, ct) == 0) break; + if (BitConverter.IsLittleEndian) Array.Reverse(lengthBuffer); int length = BitConverter.ToInt32(lengthBuffer, 0); - // Read encrypted payload var encrypted = new byte[length]; - await ReadExactAsync(_stream, encrypted, length, _cancellation.Token); - data = await DecryptDataAsync(encrypted, _aesEncryption); + await ReadExactAsync(_stream, encrypted, length, ct); + + data = await AesCryptoHelpers.DecryptDataAsync(encrypted, _aesEncryption); } else { data = new byte[_config.BufferSize]; - int bytesRead = await _stream.ReadAsync(data, 0, data.Length, _cancellation.Token); - if (bytesRead == 0) - { - break; - } + int bytesRead = await _stream.ReadAsync(data, 0, data.Length, ct); + if (bytesRead == 0) break; if (bytesRead < data.Length) { @@ -196,7 +166,6 @@ namespace EonaCat.Connections { _isConnected = false; OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving data" }); - _ = Task.Run(() => AutoReconnectAsync()); break; } @@ -205,27 +174,21 @@ namespace EonaCat.Connections await DisconnectAsync(); } - private async Task ReadExactAsync(Stream stream, byte[] buffer, int length, CancellationToken ct) { int offset = 0; while (offset < length) { int read = await stream.ReadAsync(buffer, offset, length - offset, ct); - if (read == 0) - { - return 0; - } - + if (read == 0) return 0; offset += read; } return offset; } - - private async Task ReceiveUdpDataAsync() + private async Task ReceiveUdpDataAsync(CancellationToken ct) { - while (!_cancellation.Token.IsCancellationRequested && _isConnected) + while (!ct.IsCancellationRequested && _isConnected) { try { @@ -234,10 +197,8 @@ namespace EonaCat.Connections } catch (Exception ex) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving data" }); + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving UDP data" }); _isConnected = false; - - // Start reconnect _ = Task.Run(() => AutoReconnectAsync()); break; } @@ -246,77 +207,49 @@ namespace EonaCat.Connections private async Task ProcessReceivedDataAsync(byte[] data) { - await Task.Run(() => + try { + string stringData = null; + bool isBinary = true; + try { - // Data is already decrypted if AES is enabled - // Just update stats / handle string conversion - - bool isBinary = true; - string stringData = null; - - try - { - stringData = Encoding.UTF8.GetString(data); - if (Encoding.UTF8.GetBytes(stringData).Length == data.Length) - { - isBinary = false; - } - } - catch - { - // Keep as binary - } - - OnDataReceived?.Invoke(this, new DataReceivedEventArgs - { - ClientId = "server", - Data = data, - StringData = stringData, - IsBinary = isBinary - }); + stringData = Encoding.UTF8.GetString(data); + isBinary = Encoding.UTF8.GetBytes(stringData).Length != data.Length; } - catch (Exception ex) + catch { } + + OnDataReceived?.Invoke(this, new DataReceivedEventArgs { - 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" }); - } - } - }); + ClientId = "server", + Data = data, + StringData = stringData, + IsBinary = isBinary + }); + } + catch (Exception ex) + { + var handler = _config.UseAesEncryption ? OnEncryptionError : OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error processing data" }); + } } - public async Task SendAsync(byte[] data) { - if (!_isConnected) - { - return; - } + if (!_isConnected) return; try { if (_config.UseAesEncryption && _aesEncryption != null) { - // Encrypt payload - data = await EncryptDataAsync(data, _aesEncryption); + data = await AesCryptoHelpers.EncryptDataAsync(data, _aesEncryption); - // Prepend 4-byte length for framing var lengthPrefix = BitConverter.GetBytes(data.Length); - if (BitConverter.IsLittleEndian) - { - Array.Reverse(lengthPrefix); - } + if (BitConverter.IsLittleEndian) Array.Reverse(lengthPrefix); var framed = new byte[lengthPrefix.Length + data.Length]; Buffer.BlockCopy(lengthPrefix, 0, framed, 0, lengthPrefix.Length); Buffer.BlockCopy(data, 0, framed, lengthPrefix.Length, data.Length); - data = framed; } @@ -332,63 +265,17 @@ namespace EonaCat.Connections } catch (Exception ex) { - 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" }); - } + var handler = _config.UseAesEncryption ? OnEncryptionError : OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error sending data" }); } } - - public async Task SendAsync(string message) - { - await SendAsync(Encoding.UTF8.GetBytes(message)); - } - - public async Task SendNicknameAsync(string nickname) - { - await SendAsync($"NICKNAME:{nickname}"); - } - - private async Task EncryptDataAsync(byte[] data, Aes aes) - { - using (var encryptor = aes.CreateEncryptor()) - using (var ms = new MemoryStream()) - using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) - { - await cs.WriteAsync(data, 0, data.Length); - cs.FlushFinalBlock(); - return ms.ToArray(); - } - } - - private async Task 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 async Task SendAsync(string message) => await SendAsync(Encoding.UTF8.GetBytes(message)); + public async Task SendNicknameAsync(string nickname) => await SendAsync($"NICKNAME:{nickname}"); private async Task AutoReconnectAsync() { - if (!_config.EnableAutoReconnect) - { - return; - } - - if (IsAutoReconnecting) - { - return; - } + if (!_config.EnableAutoReconnect || IsAutoReconnecting) return; int attempt = 0; @@ -397,53 +284,43 @@ namespace EonaCat.Connections attempt++; try { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Attempting to reconnect (Attempt {attempt})" }); IsAutoReconnecting = true; + OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnecting attempt {attempt}" }); await ConnectAsync(); - if (_isConnected) { IsAutoReconnecting = false; - OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnected successfully after {attempt} attempt(s)" }); + OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnected after {attempt} attempt(s)" }); break; } } - catch - { - // Do nothing - } + catch { } await Task.Delay(_config.ReconnectDelayMs); } if (!_isConnected) - { OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = "Failed to reconnect" }); - } } - public async Task DisconnectAsync() { - await Task.Run(() => - { - _isConnected = false; - _cancellation?.Cancel(); - _tcpClient?.Close(); - _udpClient?.Close(); - _stream?.Dispose(); - _aesEncryption?.Dispose(); + _isConnected = false; + _cancellation?.Cancel(); - OnDisconnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self" }); + _tcpClient?.Close(); + _udpClient?.Close(); + _stream?.Dispose(); + _aesEncryption?.Dispose(); - _ = Task.Run(() => AutoReconnectAsync()); - }); + OnDisconnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self" }); } public void Dispose() { + _cancellation?.Cancel(); DisconnectAsync().Wait(); _cancellation?.Dispose(); } } -} \ No newline at end of file +} diff --git a/EonaCat.Connections/NetworkServer.cs b/EonaCat.Connections/NetworkServer.cs index 47a983c..ddd08b4 100644 --- a/EonaCat.Connections/NetworkServer.cs +++ b/EonaCat.Connections/NetworkServer.cs @@ -7,7 +7,6 @@ using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; using System.Text; using ErrorEventArgs = EonaCat.Connections.EventArguments.ErrorEventArgs; @@ -16,7 +15,7 @@ namespace EonaCat.Connections // This file is part of the EonaCat project(s) which is released under the Apache License. // See the LICENSE file or go to https://EonaCat.com/license for full license details. - public class NetworkServer + public class NetworkServer : IDisposable { private readonly Configuration _config; private readonly Stats _stats; @@ -50,28 +49,24 @@ namespace EonaCat.Connections } } - public string IpAddress => _config != null ? _config.Host : string.Empty; - public int Port => _config != null ? _config.Port : 0; + public string IpAddress => _config?.Host ?? string.Empty; + public int Port => _config?.Port ?? 0; public async Task StartAsync() { + _serverCancellation?.Cancel(); _serverCancellation = new CancellationTokenSource(); if (_config.Protocol == ProtocolType.TCP) - { await StartTcpServerAsync(); - } else - { await StartUdpServerAsync(); - } } private async Task StartTcpServerAsync() { _tcpListener = new TcpListener(IPAddress.Parse(_config.Host), _config.Port); _tcpListener.Start(); - Console.WriteLine($"TCP Server started on {_config.Host}:{_config.Port}"); while (!_serverCancellation.Token.IsCancellationRequested) @@ -81,10 +76,7 @@ namespace EonaCat.Connections var tcpClient = await _tcpListener.AcceptTcpClientAsync(); _ = Task.Run(() => HandleTcpClientAsync(tcpClient), _serverCancellation.Token); } - catch (ObjectDisposedException) - { - break; - } + catch (ObjectDisposedException) { break; } catch (Exception ex) { OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error accepting TCP client" }); @@ -92,11 +84,6 @@ namespace EonaCat.Connections } } - public Dictionary GetClients() - { - return _clients.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - private async Task StartUdpServerAsync() { _udpListener = new UdpClient(_config.Port); @@ -109,10 +96,7 @@ namespace EonaCat.Connections var result = await _udpListener.ReceiveAsync(); _ = Task.Run(() => HandleUdpDataAsync(result), _serverCancellation.Token); } - catch (ObjectDisposedException) - { - break; - } + catch (ObjectDisposedException) { break; } catch (Exception ex) { OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving UDP data" }); @@ -135,22 +119,23 @@ namespace EonaCat.Connections try { - // Configure TCP client tcpClient.NoDelay = !_config.EnableNagle; if (_config.EnableKeepAlive) - { tcpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); - } Stream stream = tcpClient.GetStream(); - // Setup SSL if required if (_config.UseSsl) { try { - var sslStream = new SslStream(stream, false, userCertificateValidationCallback: _config.GetRemoteCertificateValidationCallback()); - await sslStream.AuthenticateAsServerAsync(_config.Certificate, _config.MutuallyAuthenticate, SslProtocols.Tls12 | SslProtocols.Tls13, _config.CheckCertificateRevocation); + var sslStream = new SslStream(stream, false, _config.GetRemoteCertificateValidationCallback()); + await sslStream.AuthenticateAsServerAsync( + _config.Certificate, + _config.MutuallyAuthenticate, + SslProtocols.Tls12 | SslProtocols.Tls13, + _config.CheckCertificateRevocation + ); stream = sslStream; client.IsSecure = true; } @@ -161,17 +146,19 @@ namespace EonaCat.Connections } } - // Setup AES encryption if required if (_config.UseAesEncryption) { try { + // Create AES object client.AesEncryption = Aes.Create(); - client.AesEncryption.GenerateKey(); - client.AesEncryption.GenerateIV(); + client.AesEncryption.KeySize = 256; + client.AesEncryption.BlockSize = 128; + client.AesEncryption.Mode = CipherMode.CBC; + client.AesEncryption.Padding = PaddingMode.PKCS7; client.IsEncrypted = true; - // Securely send raw AES key + IV + salt + password + // Send salt to client to derive key await AesKeyExchange.SendAesKeyAsync(stream, client.AesEncryption, _config.AesPassword); } catch (Exception ex) @@ -189,14 +176,10 @@ namespace EonaCat.Connections client.Stream = stream; _clients[clientId] = client; - lock (_statsLock) - { - _stats.TotalConnections++; - } + lock (_statsLock) { _stats.TotalConnections++; } OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientId, RemoteEndPoint = client.RemoteEndPoint }); - // Handle client communication await HandleClientCommunicationAsync(client); } catch (Exception ex) @@ -205,14 +188,15 @@ namespace EonaCat.Connections } finally { - await DisconnectClientAsync(clientId); + DisconnectClient(clientId); } } + + private async Task HandleUdpDataAsync(UdpReceiveResult result) { var clientKey = result.RemoteEndPoint.ToString(); - if (!_clients.TryGetValue(clientKey, out var client)) { client = new Connection @@ -222,12 +206,7 @@ namespace EonaCat.Connections ConnectedAt = DateTime.UtcNow }; _clients[clientKey] = client; - - lock (_statsLock) - { - _stats.TotalConnections++; - } - + lock (_statsLock) { _stats.TotalConnections++; } OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientKey, RemoteEndPoint = result.RemoteEndPoint }); } @@ -236,7 +215,7 @@ namespace EonaCat.Connections private async Task HandleClientCommunicationAsync(Connection client) { - var lengthBuffer = new byte[4]; // length prefix + var lengthBuffer = new byte[4]; while (!client.CancellationToken.Token.IsCancellationRequested && client.TcpClient.Connected) { @@ -246,37 +225,20 @@ namespace EonaCat.Connections if (client.IsEncrypted && client.AesEncryption != null) { - // Read 4-byte length first - int read = await ReadExactAsync(client.Stream, lengthBuffer, 4, client.CancellationToken.Token); - if (read == 0) - { - break; - } - - if (BitConverter.IsLittleEndian) - { - Array.Reverse(lengthBuffer); - } - + if (await ReadExactAsync(client.Stream, lengthBuffer, 4, client.CancellationToken.Token) == 0) break; + if (BitConverter.IsLittleEndian) Array.Reverse(lengthBuffer); int length = BitConverter.ToInt32(lengthBuffer, 0); - // Read full encrypted message var encrypted = new byte[length]; await ReadExactAsync(client.Stream, encrypted, length, client.CancellationToken.Token); - // **Decrypt once here** - data = await DecryptDataAsync(encrypted, client.AesEncryption); + data = await AesCryptoHelpers.DecryptDataAsync(encrypted, client.AesEncryption); } else { - // Non-encrypted: just read raw bytes data = new byte[_config.BufferSize]; int bytesRead = await client.Stream.ReadAsync(data, 0, data.Length, client.CancellationToken.Token); - if (bytesRead == 0) - { - break; - } - + if (bytesRead == 0) break; if (bytesRead < data.Length) { var tmp = new byte[bytesRead]; @@ -289,12 +251,7 @@ namespace EonaCat.Connections } catch (Exception ex) { - OnGeneralError?.Invoke(this, new ErrorEventArgs - { - ClientId = client.Id, - Exception = ex, - Message = "Error reading from client" - }); + OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error reading from client" }); break; } } @@ -306,17 +263,12 @@ namespace EonaCat.Connections while (offset < length) { int read = await stream.ReadAsync(buffer, offset, length - offset, ct); - if (read == 0) - { - return 0; // disconnected - } - + if (read == 0) return 0; offset += read; } return offset; } - private async Task ProcessReceivedDataAsync(Connection client, byte[] data) { try @@ -328,57 +280,26 @@ namespace EonaCat.Connections _stats.MessagesReceived++; } - // Try to decode as string, fallback to binary bool isBinary = true; string stringData = null; - try { stringData = Encoding.UTF8.GetString(data); - if (Encoding.UTF8.GetBytes(stringData).Length == data.Length) - { - isBinary = false; - } + isBinary = Encoding.UTF8.GetBytes(stringData).Length != data.Length; } catch { } - // Handle special commands if (!isBinary && stringData != null) { if (stringData.StartsWith("NICKNAME:")) { - var nickname = stringData.Substring(9); - client.Nickname = nickname; - OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs - { - ClientId = client.Id, - RemoteEndPoint = client.RemoteEndPoint, - Nickname = nickname - }); - return; - } - else if (stringData.StartsWith("[NICKNAME]", StringComparison.OrdinalIgnoreCase)) - { - var nickname = StringHelper.GetTextBetweenTags(stringData, "[NICKNAME]", "[/NICKNAME]"); - if (string.IsNullOrWhiteSpace(nickname)) - { - nickname = client.Id; // fallback to client ID if no valid nickname was provided - } - else - { - client.Nickname = nickname; - } - OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs - { - ClientId = client.Id, - RemoteEndPoint = client.RemoteEndPoint, - Nickname = nickname - }); + client.Nickname = stringData.Substring(9); + OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs { ClientId = client.Id, RemoteEndPoint = client.RemoteEndPoint, Nickname = client.Nickname }); return; } else if (stringData.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) { - await DisconnectClientAsync(client.Id); + DisconnectClient(client.Id); return; } } @@ -396,49 +317,71 @@ namespace EonaCat.Connections } catch (Exception ex) { - if (client.IsEncrypted) - { - OnEncryptionError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error processing data" }); - } - else - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error processing data" }); - } + var handler = client.IsEncrypted ? OnEncryptionError : OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error processing data" }); } } + private async Task SendDataAsync(Connection client, byte[] data) + { + try + { + // Encrypt if AES is enabled + if (client.IsEncrypted && client.AesEncryption != null) + { + data = await AesCryptoHelpers.EncryptDataAsync(data, client.AesEncryption); + + // Prepend 4-byte length (big-endian) for framing + var lengthPrefix = BitConverter.GetBytes(data.Length); + if (BitConverter.IsLittleEndian) + Array.Reverse(lengthPrefix); + + var framed = new byte[lengthPrefix.Length + data.Length]; + Buffer.BlockCopy(lengthPrefix, 0, framed, 0, lengthPrefix.Length); + Buffer.BlockCopy(data, 0, framed, lengthPrefix.Length, data.Length); + + data = framed; + } + + if (_config.Protocol == ProtocolType.TCP) + { + await client.Stream.WriteAsync(data, 0, data.Length); + await client.Stream.FlushAsync(); + } + else + { + await _udpListener.SendAsync(data, data.Length, client.RemoteEndPoint); + } + + // Update stats + client.BytesSent += data.Length; + lock (_statsLock) + { + _stats.BytesSent += data.Length; + _stats.MessagesSent++; + } + } + catch (Exception ex) + { + var handler = client.IsEncrypted ? OnEncryptionError : OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs + { + ClientId = client.Id, + Exception = ex, + Message = "Error sending data" + }); + } + } public async Task SendToClientAsync(string clientId, byte[] data) { - // Check if clientId is a guid - if (Guid.TryParse(clientId, out _)) + if (_clients.TryGetValue(clientId, out var client)) { - if (_clients.TryGetValue(clientId, out var client)) - { - await SendDataAsync(client, data); - return; - } + await SendDataAsync(client, data); + return; } - // Check if clientId is an IP:Port format - string[] parts = clientId.Split(':'); - if (parts.Length == 2) - { - if (IPAddress.TryParse(parts[0], out IPAddress ip) && int.TryParse(parts[1], out int port)) - { - IPEndPoint endPoint = new IPEndPoint(ip, port); - string clientKey = endPoint.ToString(); - - if (_clients.TryGetValue(clientKey, out var client)) - { - // If inside async method, you can use await - await SendDataAsync(client, data); - return; - } - } - } - - // Check if the client is a nickname + // Fallback: try nickname foreach (var kvp in _clients) { if (kvp.Value.Nickname != null && kvp.Value.Nickname.Equals(clientId, StringComparison.OrdinalIgnoreCase)) @@ -456,11 +399,7 @@ namespace EonaCat.Connections public async Task BroadcastAsync(byte[] data) { - var tasks = new List(); - foreach (var client in _clients.Values) - { - tasks.Add(SendDataAsync(client, data)); - } + var tasks = _clients.Values.Select(c => SendDataAsync(c, data)).ToArray(); await Task.WhenAll(tasks); } @@ -469,105 +408,24 @@ namespace EonaCat.Connections await BroadcastAsync(Encoding.UTF8.GetBytes(message)); } - private async Task SendDataAsync(Connection client, byte[] data) + private void DisconnectClient(string clientId) { - try + if (_clients.TryRemove(clientId, out var client)) { - if (client.IsEncrypted && client.AesEncryption != null) + try { - // Encrypt payload - data = await EncryptDataAsync(data, client.AesEncryption); + client.CancellationToken?.Cancel(); + client.TcpClient?.Close(); + client.Stream?.Dispose(); + client.AesEncryption?.Dispose(); - // Prepend length for safe framing - var lengthPrefix = BitConverter.GetBytes(data.Length); - if (BitConverter.IsLittleEndian) - { - Array.Reverse(lengthPrefix); - } - - var framed = new byte[lengthPrefix.Length + data.Length]; - Buffer.BlockCopy(lengthPrefix, 0, framed, 0, lengthPrefix.Length); - Buffer.BlockCopy(data, 0, framed, lengthPrefix.Length, data.Length); - - data = framed; // replace the data with framed payload + OnDisconnected?.Invoke(this, new ConnectionEventArgs { ClientId = client.Id, RemoteEndPoint = client.RemoteEndPoint, Nickname = client.Nickname }); } - - if (_config.Protocol == ProtocolType.TCP) + catch (Exception ex) { - await client.Stream.WriteAsync(data, 0, data.Length); - await client.Stream.FlushAsync(); - } - else - { - await _udpListener.SendAsync(data, data.Length, client.RemoteEndPoint); - } - - client.BytesSent += data.Length; - lock (_statsLock) - { - _stats.BytesSent += data.Length; - _stats.MessagesSent++; + OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error disconnecting client" }); } } - catch (Exception ex) - { - if (client.IsEncrypted) - { - OnEncryptionError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error encrypting/sending data" }); - } - else - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error sending data" }); - } - } - } - - - private async Task EncryptDataAsync(byte[] data, Aes aes) - { - using (var encryptor = aes.CreateEncryptor()) - using (var ms = new MemoryStream()) - using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) - { - await cs.WriteAsync(data, 0, data.Length); - cs.FlushFinalBlock(); - return ms.ToArray(); - } - } - - private async Task 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(); - } - } - - private async Task DisconnectClientAsync(string clientId) - { - await Task.Run(() => - { - if (_clients.TryRemove(clientId, out var client)) - { - try - { - client.CancellationToken?.Cancel(); - client.TcpClient?.Close(); - client.Stream?.Dispose(); - client.AesEncryption?.Dispose(); - - OnDisconnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientId, RemoteEndPoint = client.RemoteEndPoint, Nickname = client.Nickname }); - } - catch (Exception ex) - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Exception = ex, Message = "Error disconnecting client" }); - } - } - }); } public void Stop() @@ -576,19 +434,12 @@ namespace EonaCat.Connections _tcpListener?.Stop(); _udpListener?.Close(); - // Disconnect all clients - var disconnectTasks = new List(); foreach (var clientId in _clients.Keys.ToArray()) { - disconnectTasks.Add(DisconnectClientAsync(clientId)); + DisconnectClient(clientId); } - Task.WaitAll(disconnectTasks.ToArray()); } - public void Dispose() - { - Stop(); - _serverCancellation?.Dispose(); - } + public void Dispose() => Stop(); } -} \ No newline at end of file +} diff --git a/EonaCat.Connections/Processors/JsonDataProcessor.cs b/EonaCat.Connections/Processors/JsonDataProcessor.cs index 67a5670..b596fb1 100644 --- a/EonaCat.Connections/Processors/JsonDataProcessor.cs +++ b/EonaCat.Connections/Processors/JsonDataProcessor.cs @@ -7,63 +7,74 @@ using Timer = System.Timers.Timer; namespace EonaCat.Connections.Processors { - // This file is part of the EonaCat project(s) which is released under the Apache License. - // See the LICENSE file or go to https://EonaCat.com/license for full license details. - + /// + /// Processes incoming data streams into JSON or text messages per client buffer. + /// public class JsonDataProcessor : IDisposable { - public int MaxAllowedBufferSize = 20 * 1024 * 1024; - public int MaxMessagesPerBatch = 200; - private readonly ConcurrentDictionary _buffers = new(); + private const int DefaultMaxBufferSize = 20 * 1024 * 1024; // 20 MB + private const int DefaultMaxMessagesPerBatch = 200; + private static readonly TimeSpan DefaultClientBufferTimeout = TimeSpan.FromMinutes(5); + + private readonly ConcurrentDictionary _buffers = new ConcurrentDictionary(); private readonly Timer _cleanupTimer; - private readonly TimeSpan _clientBufferTimeout = TimeSpan.FromMinutes(5); private bool _isDisposed; /// - /// This clientName will be used for the buffer (if not set in the DataReceivedEventArgs). + /// Maximum allowed buffer size in bytes (default: 20 MB). + /// + public int MaxAllowedBufferSize { get; set; } = DefaultMaxBufferSize; + + /// + /// Maximum number of messages processed per batch (default: 200). + /// + public int MaxMessagesPerBatch { get; set; } = DefaultMaxMessagesPerBatch; + + /// + /// Default client name when one is not provided in . /// public string ClientName { get; set; } = Guid.NewGuid().ToString(); + public Action ProcessMessage { get; set; } + public Action ProcessTextMessage { get; set; } + + public event EventHandler OnMessageError; + public event EventHandler OnError; + private class BufferEntry { - public readonly StringBuilder Buffer = new(); + public readonly StringBuilder Buffer = new StringBuilder(); public DateTime LastUsed = DateTime.UtcNow; - public readonly object SyncRoot = new(); + public readonly object SyncRoot = new object(); } - public Action? ProcessMessage; - public Action? ProcessTextMessage; - - public event EventHandler? OnMessageError; - public event EventHandler? OnError; - public JsonDataProcessor() { - _cleanupTimer = new Timer(_clientBufferTimeout.TotalMilliseconds / 5); - _cleanupTimer.Elapsed += CleanupInactiveClients; + _cleanupTimer = new Timer(DefaultClientBufferTimeout.TotalMilliseconds / 5); _cleanupTimer.AutoReset = true; + _cleanupTimer.Elapsed += CleanupInactiveClients; _cleanupTimer.Start(); } + /// + /// Process incoming raw data. + /// public void Process(DataReceivedEventArgs e) { - if (_isDisposed) - { - throw new ObjectDisposedException(nameof(JsonDataProcessor)); - } + EnsureNotDisposed(); if (e.IsBinary) { e.StringData = Encoding.UTF8.GetString(e.Data); } - if (string.IsNullOrEmpty(e.StringData)) + if (string.IsNullOrWhiteSpace(e.StringData)) { OnError?.Invoke(this, new Exception("Received empty data.")); return; } - string clientName = !string.IsNullOrWhiteSpace(e.Nickname) ? e.Nickname : ClientName; + string clientName = string.IsNullOrWhiteSpace(e.Nickname) ? ClientName : e.Nickname; string incomingText = e.StringData.Trim(); if (incomingText.Length == 0) { @@ -71,12 +82,9 @@ namespace EonaCat.Connections.Processors } var bufferEntry = _buffers.GetOrAdd(clientName, _ => new BufferEntry()); - List? jsonChunksToProcess = null; - string? textMessageToProcess = null; lock (bufferEntry.SyncRoot) { - // Prevent growth before appending if (bufferEntry.Buffer.Length > MaxAllowedBufferSize) { bufferEntry.Buffer.Clear(); @@ -87,12 +95,14 @@ namespace EonaCat.Connections.Processors int processedCount = 0; - while (processedCount < MaxMessagesPerBatch && ExtractNextJson(bufferEntry.Buffer, out var jsonChunk)) + while (processedCount < MaxMessagesPerBatch && + ExtractNextJson(bufferEntry.Buffer, out var jsonChunk)) { ProcessDataReceived(jsonChunk, clientName); processedCount++; } + // Handle leftover non-JSON text if (bufferEntry.Buffer.Length > 0 && !ContainsJsonStructure(bufferEntry.Buffer)) { var leftover = bufferEntry.Buffer.ToString(); @@ -100,50 +110,30 @@ namespace EonaCat.Connections.Processors ProcessTextMessage?.Invoke(leftover, clientName); } } - - if (textMessageToProcess != null) - { - ProcessTextMessage?.Invoke(textMessageToProcess, clientName); - } - - if (jsonChunksToProcess != null) - { - foreach (var jsonChunk in jsonChunksToProcess) - { - ProcessDataReceived(jsonChunk, clientName); - } - } } - private void ProcessDataReceived(string? data, string clientName) + private void ProcessDataReceived(string data, string clientName) { - if (_isDisposed) - { - throw new ObjectDisposedException(nameof(JsonDataProcessor)); - } - - if (data == null) - { - return; - } - - if (string.IsNullOrEmpty(clientName)) - { - clientName = ClientName; - } - - if (string.IsNullOrWhiteSpace(data) || data.Length == 0) - { - return; - } + EnsureNotDisposed(); if (string.IsNullOrWhiteSpace(data)) { return; } + if (string.IsNullOrWhiteSpace(clientName)) + { + clientName = ClientName; + } + bool looksLikeJson = data.Length > 1 && - ((data[0] == '{' && data[data.Length - 1] == '}') || (data[0] == '[' && data[data.Length - 1] == ']')); + ((data[0] == '{' && data[data.Length - 1] == '}') || + (data[0] == '[' && data[data.Length - 1] == ']') || + data[0] == '"' || // string + char.IsDigit(data[0]) || data[0] == '-' || // numbers + data.StartsWith("true") || + data.StartsWith("false") || + data.StartsWith("null")); if (!looksLikeJson) { @@ -153,34 +143,19 @@ namespace EonaCat.Connections.Processors try { - if (data.Contains("Exception") || data.Contains("Error")) + // Try to detect JSON-encoded exceptions + if (data.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0 || + data.IndexOf("Error", StringComparison.OrdinalIgnoreCase) >= 0) { - try - { - var jsonObject = JObject.Parse(data); - var exceptionToken = jsonObject.SelectToken("Exception"); - if (exceptionToken is { Type: not JTokenType.Null }) - { - var exception = JsonHelper.ExtractException(data); - if (exception != null) - { - var currentException = new Exception(exception.Message); - OnMessageError?.Invoke(this, currentException); - } - } - } - catch (Exception) - { - // Do nothing - } + TryHandleJsonException(data); } var messages = JsonHelper.ToObjects(data); - if (messages != null) + if (messages != null && ProcessMessage != null) { foreach (var message in messages) { - ProcessMessage?.Invoke(message, clientName, data); + ProcessMessage(message, clientName, data); } } } @@ -190,8 +165,28 @@ namespace EonaCat.Connections.Processors } } + private void TryHandleJsonException(string data) + { + try + { + var jsonObject = JObject.Parse(data); + var exceptionToken = jsonObject.SelectToken("Exception"); + if (exceptionToken != null && exceptionToken.Type != JTokenType.Null) + { + var exception = JsonHelper.ExtractException(data); + if (exception != null && OnMessageError != null) + { + OnMessageError(this, new Exception(exception.Message)); + } + } + } + catch + { + // Ignore malformed exception JSON + } + } - private static bool ExtractNextJson(StringBuilder buffer, out string? json) + private static bool ExtractNextJson(StringBuilder buffer, out string json) { json = null; if (buffer.Length == 0) @@ -200,13 +195,12 @@ namespace EonaCat.Connections.Processors } int depth = 0; - bool inString = false; - bool escape = false; + bool inString = false, escape = false; int startIndex = -1; for (int i = 0; i < buffer.Length; i++) { - char currentCharacter = buffer[i]; + char c = buffer[i]; if (inString) { @@ -214,71 +208,99 @@ namespace EonaCat.Connections.Processors { escape = false; } - else if (currentCharacter == '\\') + else if (c == '\\') { escape = true; } - else if (currentCharacter == '"') + else if (c == '"') { inString = false; } } else { - if (currentCharacter == '"') + switch (c) { - inString = true; - if (depth == 0 && startIndex == -1) - { - startIndex = i; // string-only JSON - } - } - else if (currentCharacter == '{' || currentCharacter == '[') - { - if (depth == 0) - { - startIndex = i; - } + case '"': + inString = true; + if (depth == 0 && startIndex == -1) + { + startIndex = i; // string-only JSON + } - depth++; - } - else if (currentCharacter == '}' || currentCharacter == ']') - { - depth--; - if (depth == 0 && startIndex != -1) - { - json = buffer.ToString(startIndex, i - startIndex + 1); - buffer.Remove(0, i + 1); - return true; - } - } - else if (depth == 0 && startIndex == -1 && - (char.IsDigit(currentCharacter) || currentCharacter == '-' || currentCharacter == 't' || currentCharacter == 'f' || currentCharacter == 'n')) - { - startIndex = i; + break; - // Find token end - int tokenEnd = FindPrimitiveEnd(buffer, i); - json = buffer.ToString(startIndex, tokenEnd - startIndex); - buffer.Remove(0, tokenEnd); - return true; + case '{': + case '[': + if (depth == 0) + { + startIndex = i; + } + + depth++; + break; + + case '}': + case ']': + depth--; + if (depth == 0 && startIndex != -1) + { + int length = i - startIndex + 1; + json = buffer.ToString(startIndex, length); + buffer.Remove(0, i + 1); + return true; + } + break; + + default: + if (depth == 0 && startIndex == -1 && + (char.IsDigit(c) || c == '-' || c == 't' || c == 'f' || c == 'n')) + { + startIndex = i; + int tokenEnd = FindPrimitiveEnd(buffer, i); + json = buffer.ToString(startIndex, tokenEnd - startIndex); + buffer.Remove(0, tokenEnd); + return true; + } + break; } } } + return false; } private static int FindPrimitiveEnd(StringBuilder buffer, int startIndex) { - for (int i = startIndex; i < buffer.Length; i++) + // Keywords: true/false/null + if (buffer.Length >= startIndex + 4 && buffer.ToString(startIndex, 4) == "true") + { + return startIndex + 4; + } + + if (buffer.Length >= startIndex + 5 && buffer.ToString(startIndex, 5) == "false") + { + return startIndex + 5; + } + + if (buffer.Length >= startIndex + 4 && buffer.ToString(startIndex, 4) == "null") + { + return startIndex + 4; + } + + // Numbers: scan until non-number/decimal/exponent + int i = startIndex; + while (i < buffer.Length) { char c = buffer[i]; - if (char.IsWhiteSpace(c) || c == ',' || c == ']' || c == '}') + if (!(char.IsDigit(c) || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E')) { - return i; + break; } + + i++; } - return buffer.Length; + return i; } private static bool ContainsJsonStructure(StringBuilder buffer) @@ -286,9 +308,7 @@ namespace EonaCat.Connections.Processors for (int i = 0; i < buffer.Length; i++) { char c = buffer[i]; - if (c == '{' || c == '[' || c == '"' || - c == 't' || c == 'f' || c == 'n' || - c == '-' || char.IsDigit(c)) + if (c == '{' || c == '[' || c == '"' || c == 't' || c == 'f' || c == 'n' || c == '-' || char.IsDigit(c)) { return true; } @@ -296,18 +316,22 @@ namespace EonaCat.Connections.Processors return false; } - private void CleanupInactiveClients(object? sender, ElapsedEventArgs e) + private void CleanupInactiveClients(object sender, ElapsedEventArgs e) { var now = DateTime.UtcNow; foreach (var kvp in _buffers) { var bufferEntry = kvp.Value; - if (now - bufferEntry.LastUsed > _clientBufferTimeout && _buffers.TryRemove(kvp.Key, out var removed)) + if (now - bufferEntry.LastUsed > DefaultClientBufferTimeout) { - lock (removed.SyncRoot) + BufferEntry removed; + if (_buffers.TryRemove(kvp.Key, out removed)) { - removed.Buffer.Clear(); + lock (removed.SyncRoot) + { + removed.Buffer.Clear(); + } } } } @@ -320,7 +344,8 @@ namespace EonaCat.Connections.Processors return; } - if (_buffers.TryRemove(clientName, out var removed)) + BufferEntry removed; + if (_buffers.TryRemove(clientName, out removed)) { lock (removed.SyncRoot) { @@ -329,6 +354,14 @@ namespace EonaCat.Connections.Processors } } + private void EnsureNotDisposed() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(JsonDataProcessor)); + } + } + public void Dispose() { if (_isDisposed) @@ -336,25 +369,30 @@ namespace EonaCat.Connections.Processors return; } - _isDisposed = true; - - _cleanupTimer.Stop(); - _cleanupTimer.Elapsed -= CleanupInactiveClients; - _cleanupTimer.Dispose(); - - foreach (var bufferEntry in _buffers.Values) + try { - lock (bufferEntry.SyncRoot) - { - bufferEntry.Buffer.Clear(); - } - } - _buffers.Clear(); + _cleanupTimer.Stop(); + _cleanupTimer.Elapsed -= CleanupInactiveClients; + _cleanupTimer.Dispose(); - ProcessMessage = null; - ProcessTextMessage = null; - OnMessageError = null; - OnError = null; + foreach (var bufferEntry in _buffers.Values) + { + lock (bufferEntry.SyncRoot) + { + bufferEntry.Buffer.Clear(); + } + } + _buffers.Clear(); + + ProcessMessage = null; + ProcessTextMessage = null; + OnMessageError = null; + OnError = null; + } + finally + { + _isDisposed = true; + } } } }