diff --git a/EonaCat.Connections.Client/Program.cs b/EonaCat.Connections.Client/Program.cs index 6f6100b..16e395b 100644 --- a/EonaCat.Connections.Client/Program.cs +++ b/EonaCat.Connections.Client/Program.cs @@ -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!"); diff --git a/EonaCat.Connections/DisconnectReason.cs b/EonaCat.Connections/DisconnectReason.cs new file mode 100644 index 0000000..771e7f1 --- /dev/null +++ b/EonaCat.Connections/DisconnectReason.cs @@ -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 + } +} diff --git a/EonaCat.Connections/EonaCat.Connections.csproj b/EonaCat.Connections/EonaCat.Connections.csproj index 36addc8..d004030 100644 --- a/EonaCat.Connections/EonaCat.Connections.csproj +++ b/EonaCat.Connections/EonaCat.Connections.csproj @@ -11,7 +11,7 @@ EonaCat (Jeroen Saey) readme.md EonaCat.Connections - 1.0.7 + 1.0.8 EonaCat (Jeroen Saey) LICENSE EonaCat.png @@ -36,6 +36,7 @@ + diff --git a/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs b/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs index f8eb819..2b55fe7 100644 --- a/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs +++ b/EonaCat.Connections/EventArguments/ConnectionEventArgs.cs @@ -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; + } } } \ No newline at end of file diff --git a/EonaCat.Connections/Helpers/AesCryptoHelpers.cs b/EonaCat.Connections/Helpers/AesCryptoHelpers.cs deleted file mode 100644 index 7fd2415..0000000 --- a/EonaCat.Connections/Helpers/AesCryptoHelpers.cs +++ /dev/null @@ -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 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 8391b4f..3e09bd4 100644 --- a/EonaCat.Connections/Helpers/AesKeyExchange.cs +++ b/EonaCat.Connections/Helpers/AesKeyExchange.cs @@ -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 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 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 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 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 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 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; + } } } diff --git a/EonaCat.Connections/IClientPlugin.cs b/EonaCat.Connections/IClientPlugin.cs new file mode 100644 index 0000000..e07ff23 --- /dev/null +++ b/EonaCat.Connections/IClientPlugin.cs @@ -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); + } +} diff --git a/EonaCat.Connections/IServerPlugin.cs b/EonaCat.Connections/IServerPlugin.cs new file mode 100644 index 0000000..e3aa103 --- /dev/null +++ b/EonaCat.Connections/IServerPlugin.cs @@ -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. + + /// + /// Defines the contract for plugins that extend the behavior of the NetworkServer. + /// Implement this interface to hook into server events such as + /// client connections, disconnections, message handling, and lifecycle events. + /// + public interface IServerPlugin + { + /// + /// Gets the unique name of this plugin (used for logging/error reporting). + /// + string Name { get; } + + /// + /// Called when the server has started successfully. + /// + /// The server instance that started. + void OnServerStarted(NetworkServer server); + + /// + /// Called when the server has stopped. + /// + /// The server instance that stopped. + void OnServerStopped(NetworkServer server); + + /// + /// Called when a client successfully connects. + /// + /// The connected client. + void OnClientConnected(Connection client); + + /// + /// Called when a client disconnects. + /// + /// The client that disconnected. + /// The reason for disconnection. + /// Optional exception if the disconnect was caused by an error. + void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception); + + /// + /// Called when data is received from a client. + /// + /// The client that sent the data. + /// The raw bytes received. + /// The decoded string (if text-based, otherwise null). + /// True if the message is binary data, false if text. + void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary); + } +} diff --git a/EonaCat.Connections/Models/Connection.cs b/EonaCat.Connections/Models/Connection.cs index 1e8fdd6..2efa4a5 100644 --- a/EonaCat.Connections/Models/Connection.cs +++ b/EonaCat.Connections/Models/Connection.cs @@ -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; } } \ No newline at end of file diff --git a/EonaCat.Connections/NetworkClient.cs b/EonaCat.Connections/NetworkClient.cs index afb8480..71f4c63 100644 --- a/EonaCat.Connections/NetworkClient.cs +++ b/EonaCat.Connections/NetworkClient.cs @@ -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 _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 OnConnected; public event EventHandler OnDataReceived; public event EventHandler OnDisconnected; @@ -49,120 +45,172 @@ namespace EonaCat.Connections public event EventHandler OnEncryptionError; public event EventHandler OnGeneralError; - public string IpAddress => _config?.Host ?? string.Empty; - public int Port => _config?.Port ?? 0; + private readonly List _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 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 } } - /// Join a room (server should recognize this command) - 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 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(); } } } diff --git a/EonaCat.Connections/NetworkServer.cs b/EonaCat.Connections/NetworkServer.cs index b104676..bd6abdd 100644 --- a/EonaCat.Connections/NetworkServer.cs +++ b/EonaCat.Connections/NetworkServer.cs @@ -12,7 +12,10 @@ using ErrorEventArgs = EonaCat.Connections.EventArguments.ErrorEventArgs; namespace EonaCat.Connections { - public class NetworkServer : IDisposable + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/license for full license details. + + public class NetworkServer { private readonly Configuration _config; private readonly Stats _stats; @@ -21,13 +24,8 @@ namespace EonaCat.Connections private UdpClient _udpListener; private CancellationTokenSource _serverCancellation; private readonly object _statsLock = new object(); - private readonly object _serverLock = new object(); - - private readonly ConcurrentDictionary> _rooms = new(); - private readonly ConcurrentDictionary> _roomHistory = new(); - private readonly ConcurrentDictionary _roomPasswords = new(); - private readonly ConcurrentDictionary _rateLimits = new(); - private readonly int _maxMessagesPerSecond = 10; + private readonly object _tcpLock = new object(); + private readonly object _udpLock = new object(); public event EventHandler OnConnected; public event EventHandler OnConnectedWithNickname; @@ -37,6 +35,42 @@ namespace EonaCat.Connections public event EventHandler OnEncryptionError; public event EventHandler OnGeneralError; + public bool IsStarted => _serverCancellation != null && !_serverCancellation.IsCancellationRequested; + public bool IsSecure => _config != null && (_config.UseSsl || _config.UseAesEncryption); + public bool IsEncrypted => _config != null && _config.UseAesEncryption; + public int ActiveConnections => _clients.Count; + public long TotalConnections => _stats.TotalConnections; + public long BytesSent => _stats.BytesSent; + public long BytesReceived => _stats.BytesReceived; + public long MessagesSent => _stats.MessagesSent; + public long MessagesReceived => _stats.MessagesReceived; + public double MessagesPerSecond => _stats.MessagesPerSecond; + public TimeSpan Uptime => _stats.Uptime; + public DateTime StartTime => _stats.StartTime; + public int MaxConnections => _config != null ? _config.MaxConnections : 0; + public ProtocolType Protocol => _config != null ? _config.Protocol : ProtocolType.TCP; + private int _tcpRunning = 0; + private int _udpRunning = 0; + + private readonly List _plugins = new List(); + public void RegisterPlugin(IServerPlugin plugin) => _plugins.Add(plugin); + public void UnregisterPlugin(IServerPlugin plugin) => _plugins.Remove(plugin); + private void InvokePlugins(Action action) + { + foreach (var plugin in _plugins) + { + try { action(plugin); } + catch (Exception ex) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = ex, + Message = $"Plugin {plugin.Name} failed" + }); + } + } + } + public NetworkServer(Configuration config) { _config = config; @@ -53,137 +87,171 @@ namespace EonaCat.Connections } } - public string IpAddress => _config?.Host ?? string.Empty; - public int Port => _config?.Port ?? 0; + public string IpAddress => _config != null ? _config.Host : string.Empty; + public int Port => _config != null ? _config.Port : 0; public async Task StartAsync() { - lock (_serverLock) - { - if (_serverCancellation != null && !_serverCancellation.IsCancellationRequested) - { - // Server is already running - return; - } + _serverCancellation = new CancellationTokenSource(); - _serverCancellation = new CancellationTokenSource(); + if (_config.Protocol == ProtocolType.TCP) + { + await StartTcpServerAsync(); + } + else + { + await StartUdpServerAsync(); + } + + InvokePlugins(p => p.OnServerStarted(this)); + } + + public async Task StartTcpServerAsync() + { + if (Interlocked.CompareExchange(ref _tcpRunning, 1, 0) == 1) + { + Console.WriteLine("TCP Server is already running."); + return; } try { - if (_config.Protocol == ProtocolType.TCP) + lock (_tcpLock) { - await StartTcpServerAsync(); - } - else - { - await StartUdpServerAsync(); - } - } - catch (Exception ex) - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error starting server" }); - } - } - - private async Task StartTcpServerAsync() - { - lock (_serverLock) - { - if (_tcpListener != null) - { - _tcpListener.Stop(); + _tcpListener = new TcpListener(IPAddress.Parse(_config.Host), _config.Port); + _tcpListener.Start(); } - _tcpListener = new TcpListener(IPAddress.Parse(_config.Host), _config.Port); - _tcpListener.Start(); - } + Console.WriteLine($"TCP Server started on {_config.Host}:{_config.Port}"); - Console.WriteLine($"TCP Server started on {_config.Host}:{_config.Port}"); - - while (!_serverCancellation.Token.IsCancellationRequested) - { - try + while (!_serverCancellation.Token.IsCancellationRequested) { - var tcpClient = await _tcpListener.AcceptTcpClientAsync(); - _ = Task.Run(() => HandleTcpClientAsync(tcpClient), _serverCancellation.Token); - } - catch (ObjectDisposedException) { break; } - catch (Exception ex) - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error accepting TCP client" }); - } - } - } + TcpClient? tcpClient = null; - - private readonly TimeSpan _udpCleanupInterval = TimeSpan.FromMinutes(1); - - private async Task CleanupInactiveUdpClientsAsync() - { - while (!_serverCancellation.Token.IsCancellationRequested) - { - var now = DateTime.UtcNow; - foreach (var kvp in _clients.ToArray()) - { - var client = kvp.Value; - if (client.TcpClient == null && (now - client.LastActive) > TimeSpan.FromMinutes(5)) + try { - DisconnectClient(client.Id); + lock (_tcpLock) + { + if (_tcpListener == null) + { + break; + } + } + + tcpClient = await _tcpListener!.AcceptTcpClientAsync().ConfigureAwait(false); + _ = Task.Run(() => HandleTcpClientAsync(tcpClient), _serverCancellation.Token); + } + catch (ObjectDisposedException) + { + break; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("Not listening")) + { + break; + } + catch (Exception ex) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = ex, + Message = "Error accepting TCP client" + }); } } - await Task.Delay(_udpCleanupInterval, _serverCancellation.Token); } - } - - private bool CheckRateLimit(string clientId) - { - var now = DateTime.UtcNow; - - _rateLimits.TryGetValue(clientId, out var record); - if ((now - record.Timestamp).TotalSeconds > 1) + finally { - record = (0, now); + StopTcpServer(); } - - record.Count++; - _rateLimits[clientId] = record; - - return record.Count <= _maxMessagesPerSecond; } - - - private async Task StartUdpServerAsync() + private void StopTcpServer() { - lock (_serverLock) + lock (_tcpLock) + { + _tcpListener?.Stop(); + _tcpListener = null; + } + + Interlocked.Exchange(ref _tcpRunning, 0); + } + + public Dictionary GetClients() + { + return _clients.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + public async Task StartUdpServerAsync() + { + if (Interlocked.CompareExchange(ref _udpRunning, 1, 0) == 1) + { + Console.WriteLine("UDP Server is already running."); + return; + } + + try + { + lock (_udpLock) + { + _udpListener = new UdpClient(_config.Port); + } + + Console.WriteLine($"UDP Server started on {_config.Host}:{_config.Port}"); + + while (!_serverCancellation.Token.IsCancellationRequested) + { + try + { + UdpReceiveResult result; + + lock (_udpLock) + { + if (_udpListener == null) + { + break; + } + } + + result = await _udpListener!.ReceiveAsync().ConfigureAwait(false); + + _ = Task.Run(() => HandleUdpDataAsync(result), _serverCancellation.Token); + } + catch (ObjectDisposedException) + { + break; + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted) + { + break; + } + catch (Exception ex) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + Exception = ex, + Message = "Error receiving UDP data" + }); + } + } + } + finally + { + StopUdpServer(); + } + } + + private void StopUdpServer() + { + lock (_udpLock) { _udpListener?.Close(); - _udpListener = new UdpClient(_config.Port); + _udpListener?.Dispose(); + _udpListener = null; } - Console.WriteLine($"UDP Server started on {_config.Host}:{_config.Port}"); - _ = Task.Run(() => CleanupInactiveUdpClientsAsync(), _serverCancellation.Token); - - while (!_serverCancellation.Token.IsCancellationRequested) - { - try - { - var result = await _udpListener.ReceiveAsync(); - _ = Task.Run(() => HandleUdpDataAsync(result), _serverCancellation.Token); - } - catch (ObjectDisposedException) - { - break; - } - catch (Exception ex) - { - OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving UDP data" }); - } - } + Interlocked.Exchange(ref _udpRunning, 0); } - private async Task HandleTcpClientAsync(TcpClient tcpClient) { var clientId = Guid.NewGuid().ToString(); @@ -194,8 +262,7 @@ namespace EonaCat.Connections RemoteEndPoint = (IPEndPoint)tcpClient.Client.RemoteEndPoint, ConnectedAt = DateTime.UtcNow, LastActive = DateTime.UtcNow, - CancellationToken = new CancellationTokenSource(), - SendLock = new SemaphoreSlim(1, 1) + CancellationToken = new CancellationTokenSource() }; try @@ -212,20 +279,14 @@ namespace EonaCat.Connections { try { - var sslStream = new SslStream(stream, false, _config.GetRemoteCertificateValidationCallback()); - await sslStream.AuthenticateAsServerAsync( - _config.Certificate, - _config.MutuallyAuthenticate, - SslProtocols.Tls12 | SslProtocols.Tls13, - _config.CheckCertificateRevocation - ); + var sslStream = new SslStream(stream, false, userCertificateValidationCallback: _config.GetRemoteCertificateValidationCallback()); + await sslStream.AuthenticateAsServerAsync(_config.Certificate, _config.MutuallyAuthenticate, SslProtocols.Tls12 | SslProtocols.Tls13, _config.CheckCertificateRevocation); stream = sslStream; client.IsSecure = true; } catch (Exception ex) { - var handler = OnSslError; - handler?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Exception = ex, Message = "SSL authentication failed" }); + OnSslError?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Nickname = client.Nickname, Exception = ex, Message = "SSL authentication failed" }); return; } } @@ -235,18 +296,21 @@ namespace EonaCat.Connections try { client.AesEncryption = Aes.Create(); - client.AesEncryption.KeySize = 256; - client.AesEncryption.BlockSize = 128; - client.AesEncryption.Mode = CipherMode.CBC; - client.AesEncryption.Padding = PaddingMode.PKCS7; + client.AesEncryption.GenerateKey(); + client.AesEncryption.GenerateIV(); client.IsEncrypted = true; await AesKeyExchange.SendAesKeyAsync(stream, client.AesEncryption, _config.AesPassword); } catch (Exception ex) { - var handler = OnEncryptionError; - handler?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Exception = ex, Message = "AES setup failed" }); + OnEncryptionError?.Invoke(this, new ErrorEventArgs + { + ClientId = clientId, + Nickname = client.Nickname, + Exception = ex, + Message = "AES setup failed" + }); return; } } @@ -254,42 +318,47 @@ namespace EonaCat.Connections client.Stream = stream; _clients[clientId] = client; - lock (_statsLock) { _stats.TotalConnections++; } + lock (_statsLock) + { + _stats.TotalConnections++; + } - var connectedHandler = OnConnected; - connectedHandler?.Invoke(this, new ConnectionEventArgs { ClientId = clientId, RemoteEndPoint = client.RemoteEndPoint }); + OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientId, RemoteEndPoint = client.RemoteEndPoint, Nickname = client.Nickname }); + InvokePlugins(p => p.OnClientConnected(client)); await HandleClientCommunicationAsync(client); } catch (Exception ex) { - var handler = OnGeneralError; - handler?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Exception = ex, Message = "Error handling TCP client" }); + await DisconnectClientAsync(clientId, DisconnectReason.Error, ex); } finally { - DisconnectClient(clientId); + await DisconnectClientAsync(clientId, DisconnectReason.Unknown); } } private async Task HandleUdpDataAsync(UdpReceiveResult result) { var clientKey = result.RemoteEndPoint.ToString(); + if (!_clients.TryGetValue(clientKey, out var client)) { client = new Connection { Id = clientKey, RemoteEndPoint = result.RemoteEndPoint, - ConnectedAt = DateTime.UtcNow, - SendLock = new SemaphoreSlim(1, 1) + ConnectedAt = DateTime.UtcNow }; _clients[clientKey] = client; - lock (_statsLock) { _stats.TotalConnections++; } + lock (_statsLock) + { + _stats.TotalConnections++; + } - var handler = OnConnected; - handler?.Invoke(this, new ConnectionEventArgs { ClientId = clientKey, RemoteEndPoint = result.RemoteEndPoint }); + OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientKey, RemoteEndPoint = result.RemoteEndPoint }); + InvokePlugins(p => p.OnClientConnected(client)); } await ProcessReceivedDataAsync(client, result.Buffer); @@ -307,7 +376,8 @@ namespace EonaCat.Connections if (client.IsEncrypted && client.AesEncryption != null) { - if (await ReadExactAsync(client.Stream, lengthBuffer, 4, client.CancellationToken.Token) == 0) + int read = await ReadExactAsync(client.Stream, lengthBuffer, 4, client, client.CancellationToken.Token); + if (read == 0) { break; } @@ -320,66 +390,104 @@ namespace EonaCat.Connections int length = BitConverter.ToInt32(lengthBuffer, 0); var encrypted = new byte[length]; - await ReadExactAsync(client.Stream, encrypted, length, client.CancellationToken.Token); + await ReadExactAsync(client.Stream, encrypted, length, client, client.CancellationToken.Token); - data = await AesCryptoHelpers.DecryptDataAsync(encrypted, client.AesEncryption); + data = await AesKeyExchange.DecryptDataAsync(encrypted, client.AesEncryption); } else { data = new byte[_config.BufferSize]; - int bytesRead = await client.Stream.ReadAsync(data, 0, data.Length, client.CancellationToken.Token); - if (bytesRead == 0) - { - break; - } - if (bytesRead < data.Length) + await client.ReadLock.WaitAsync(client.CancellationToken.Token); // NEW + try { - var tmp = new byte[bytesRead]; - Array.Copy(data, tmp, bytesRead); - data = tmp; + int bytesRead = await client.Stream.ReadAsync(data, 0, data.Length, client.CancellationToken.Token); + if (bytesRead == 0) + { + await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed); + return; + } + + if (bytesRead < data.Length) + { + var tmp = new byte[bytesRead]; + Array.Copy(data, tmp, bytesRead); + data = tmp; + } + } + catch (IOException ioEx) + { + await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed, ioEx); + return; + } + catch (SocketException sockEx) + { + await DisconnectClientAsync(client.Id, DisconnectReason.Error, sockEx); + return; + } + catch (OperationCanceledException) + { + await DisconnectClientAsync(client.Id, DisconnectReason.Timeout); + return; + } + catch (Exception ex) + { + await DisconnectClientAsync(client.Id, DisconnectReason.Error, ex); + return; + } + finally + { + client.ReadLock.Release(); } } await ProcessReceivedDataAsync(client, data); } + catch (IOException ioEx) + { + await DisconnectClientAsync(client.Id, DisconnectReason.RemoteClosed, ioEx); + } + catch (SocketException sockEx) + { + await DisconnectClientAsync(client.Id, DisconnectReason.Error, sockEx); + } catch (Exception ex) { - var handler = OnGeneralError; - handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error reading from client" }); - break; + await DisconnectClientAsync(client.Id, DisconnectReason.Error, ex); } } } - private async Task ReadExactAsync(Stream stream, byte[] buffer, int length, CancellationToken ct) + private async Task ReadExactAsync(Stream stream, byte[] buffer, int length, Connection client, CancellationToken ct) { - int offset = 0; - while (offset < length) + await client.ReadLock.WaitAsync(ct); // NEW + try { - int read = await stream.ReadAsync(buffer, offset, length - offset, ct); - if (read == 0) + int offset = 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 + { + client.ReadLock.Release(); } - return offset; } + private async Task ProcessReceivedDataAsync(Connection client, byte[] data) { try { - if (!CheckRateLimit(client.Id)) - { - // Throttle the client - await Task.Delay(100); - return; - } - - client.BytesReceived += data.Length; + client.AddBytesReceived(data.Length); lock (_statsLock) { _stats.BytesReceived += data.Length; @@ -388,10 +496,14 @@ namespace EonaCat.Connections 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 { } @@ -399,76 +511,47 @@ namespace EonaCat.Connections { if (stringData.StartsWith("NICKNAME:")) { - client.Nickname = stringData.Substring(9); - var handler = OnConnectedWithNickname; - handler?.Invoke(this, new ConnectionEventArgs + var nickname = stringData.Substring(9); + client.Nickname = nickname; + OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs { ClientId = client.Id, RemoteEndPoint = client.RemoteEndPoint, - Nickname = client.Nickname + Nickname = nickname }); + _clients[client.Id] = client; + return; + } + else if (stringData.StartsWith("[NICKNAME]", StringComparison.OrdinalIgnoreCase)) + { + var nickname = StringHelper.GetTextBetweenTags(stringData, "[NICKNAME]", "[/NICKNAME]"); + if (string.IsNullOrWhiteSpace(nickname)) + { + nickname = client.Id; + } + else + { + client.Nickname = nickname; + } + + OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs + { + ClientId = client.Id, + RemoteEndPoint = client.RemoteEndPoint, + Nickname = nickname + }); + _clients[client.Id] = client; return; } else if (stringData.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) { - DisconnectClient(client.Id); + await DisconnectClientAsync(client.Id, DisconnectReason.ClientRequested); return; } - else if (stringData.StartsWith("JOIN_ROOM:")) - { - string roomName = stringData.Substring(10); - var bag = _rooms.GetOrAdd(roomName, _ => new ConcurrentBag()); - if (!bag.Contains(client.Id)) - { - bag.Add(client.Id); - } - - return; - } - else if (stringData.StartsWith("LEAVE_ROOM:")) - { - string roomName = stringData.Substring(11); - if (_rooms.TryGetValue(roomName, out var bag)) - { - _rooms[roomName] = new ConcurrentBag(bag.Where(id => id != client.Id)); - } - return; - } - else if (stringData.StartsWith("ROOM_MSG:")) - { - var parts = stringData.Substring(9).Split(new[] { ":" }, 2, StringSplitOptions.None); - if (parts.Length == 2) - { - string roomName = parts[0]; - string msg = parts[1]; - - if (_rooms.TryGetValue(roomName, out var clients)) - { - // Broadcast to room - var tasks = clients.Where(id => _clients.ContainsKey(id)) - .Select(id => SendDataAsync(_clients[id], Encoding.UTF8.GetBytes($"{client.Nickname}:{msg}"))); - await Task.WhenAll(tasks); - - // Add to room history - var history = _roomHistory.GetOrAdd(roomName, _ => new ConcurrentQueue()); - history.Enqueue($"{client.Nickname}:{msg}"); - while (history.Count > 100) - { - history.TryDequeue(out _); - } - } - } - return; - } - else - { - await HandleCommand(client, stringData); - } } client.LastActive = DateTime.UtcNow; - var dataHandler = OnDataReceived; - dataHandler?.Invoke(this, new DataReceivedEventArgs + OnDataReceived?.Invoke(this, new DataReceivedEventArgs { ClientId = client.Id, Nickname = client.Nickname, @@ -477,14 +560,71 @@ namespace EonaCat.Connections StringData = stringData, IsBinary = isBinary }); + InvokePlugins(p => p.OnDataReceived(client, data, stringData, isBinary)); } catch (Exception ex) { - var handler = client.IsEncrypted ? OnEncryptionError : OnGeneralError; - handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error processing data", Nickname = client.Nickname }); + if (client.IsEncrypted) + { + OnEncryptionError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Nickname = client.Nickname, Exception = ex, Message = "Error processing data" }); + } + else + { + OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Nickname = client.Nickname, Exception = ex, Message = "Error processing data" }); + } } } + public async Task SendToClientAsync(string clientId, byte[] data) + { + var client = GetClient(clientId); + if (client != null && client.Count > 0) + { + foreach (var current in client) + { + await SendDataAsync(current, data); + } + } + } + + public async Task SendToClientAsync(string clientId, string message) + { + await SendToClientAsync(clientId, Encoding.UTF8.GetBytes(message)); + } + + public async Task SendFromClientToClientAsync(string fromClientId, string toClientId, byte[] data) + { + var fromClient = GetClient(fromClientId); + var toClient = GetClient(toClientId); + if (fromClient != null && toClient != null && fromClient.Count > 0 && toClient.Count > 0) + { + foreach (var current in toClient) + { + await SendDataAsync(current, data); + } + } + } + + public async Task SendFromClientToClientAsync(string fromClientId, string toClientId, string message) + { + await SendFromClientToClientAsync(fromClientId, toClientId, Encoding.UTF8.GetBytes(message)); + } + + public async Task BroadcastAsync(byte[] data) + { + var tasks = new List(); + foreach (var client in _clients.Values) + { + tasks.Add(SendDataAsync(client, data)); + } + await Task.WhenAll(tasks); + } + + public async Task BroadcastAsync(string message) + { + await BroadcastAsync(Encoding.UTF8.GetBytes(message)); + } + private async Task SendDataAsync(Connection client, byte[] data) { await client.SendLock.WaitAsync(); @@ -492,7 +632,8 @@ namespace EonaCat.Connections { if (client.IsEncrypted && client.AesEncryption != null) { - data = await AesCryptoHelpers.EncryptDataAsync(data, client.AesEncryption); + data = await AesKeyExchange.EncryptDataAsync(data, client.AesEncryption); + var lengthPrefix = BitConverter.GetBytes(data.Length); if (BitConverter.IsLittleEndian) { @@ -502,7 +643,6 @@ namespace EonaCat.Connections 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; } @@ -516,7 +656,7 @@ namespace EonaCat.Connections await _udpListener.SendAsync(data, data.Length, client.RemoteEndPoint); } - client.BytesSent += data.Length; + client.AddBytesSent(data.Length); lock (_statsLock) { _stats.BytesSent += data.Length; @@ -526,7 +666,7 @@ namespace EonaCat.Connections catch (Exception ex) { var handler = client.IsEncrypted ? OnEncryptionError : OnGeneralError; - handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error sending data", Nickname = client.Nickname }); + handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Nickname = client.Nickname, Exception = ex, Message = "Error sending data" }); } finally { @@ -534,256 +674,110 @@ namespace EonaCat.Connections } } - public async Task SendFileAsync(Connection client, byte[] fileData, int chunkSize = 8192) + public async Task DisconnectClientAsync(string clientId, DisconnectReason reason = DisconnectReason.Unknown, Exception exception = null) { - int offset = 0; - while (offset < fileData.Length) + if (!_clients.TryRemove(clientId, out var client)) { - int size = Math.Min(chunkSize, fileData.Length - offset); - var chunk = new byte[size]; - Array.Copy(fileData, offset, chunk, 0, size); - await SendDataAsync(client, chunk); - offset += size; - } - } - - public void AddMessageToRoomHistory(string roomName, string message) - { - var queue = _roomHistory.GetOrAdd(roomName, _ => new ConcurrentQueue()); - queue.Enqueue(message); - if (queue.Count > 100) - { - queue.TryDequeue(out _); - } - } - - public bool SetRoomPassword(string roomName, string password) - { - _roomPasswords[roomName] = password; - return true; - } - - public bool JoinRoomWithPassword(string clientId, string roomName, string password) - { - if (_roomPasswords.TryGetValue(roomName, out var storedPassword) && storedPassword == password) - { - JoinRoom(clientId, roomName); - return true; - } - return false; - } - - - public IEnumerable GetRoomHistory(string roomName) - { - if (_roomHistory.TryGetValue(roomName, out var queue)) - { - return queue.ToArray(); - } - - return Enumerable.Empty(); - } - - public async Task SendPrivateMessageAsync(string fromNickname, string toNickname, string message) - { - var tasks = _clients.Values - .Where(c => !string.IsNullOrEmpty(c.Nickname) && c.Nickname.Equals(toNickname, StringComparison.OrdinalIgnoreCase)) - .Select(c => SendDataAsync(c, Encoding.UTF8.GetBytes($"[PM from {fromNickname}]: {message}"))) - .ToArray(); - await Task.WhenAll(tasks); - } - - - public void GetAllClients(out List clients) - { - clients = _clients.Values.ToList(); - } - - public Connection GetClientById(string clientId) - { - if (_clients.TryGetValue(clientId, out var client)) - { - return client; - } - return _clients.Values.FirstOrDefault(c => c.Nickname != null && c.Nickname.Equals(clientId, StringComparison.OrdinalIgnoreCase)); - } - - public async Task SendToClientAsync(string clientId, byte[] data) - { - if (_clients.TryGetValue(clientId, out var client)) - { - await SendDataAsync(client, data); return; } - foreach (var kvp in _clients) + if (!client.MarkDisconnected()) { - if (kvp.Value.Nickname != null && kvp.Value.Nickname.Equals(clientId, StringComparison.OrdinalIgnoreCase)) - { - await SendDataAsync(kvp.Value, data); - return; - } + return; } - } - public async Task SendToClientAsync(string clientId, string message) - { - await SendToClientAsync(clientId, Encoding.UTF8.GetBytes(message)); - } - - public async Task BroadcastAsync(byte[] data) - { - var tasks = _clients.Values.Select(c => SendDataAsync(c, data)).ToArray(); - await Task.WhenAll(tasks); - } - - public async Task BroadcastAsync(string message) - { - await BroadcastAsync(Encoding.UTF8.GetBytes(message)); - } - - private void DisconnectClient(string clientId) - { - if (_clients.TryRemove(clientId, out var client)) + await Task.Run(() => { try { - CleanupClientFromRooms(clientId); - client.CancellationToken?.Cancel(); client.TcpClient?.Close(); client.Stream?.Dispose(); client.AesEncryption?.Dispose(); + client.SendLock.Dispose(); - foreach (var room in _rooms.Keys.ToList()) - { - if (_rooms.TryGetValue(room, out var bag)) + Volatile.Read(ref OnDisconnected)?.Invoke(this, + new ConnectionEventArgs { - _rooms[room] = new ConcurrentBag(bag.Where(id => id != clientId)); - } - } + ClientId = clientId, + Nickname = client.Nickname, + RemoteEndPoint = client.RemoteEndPoint, + Reason = ConnectionEventArgs.Determine(reason, exception), + Exception = exception + }); - var handler = OnDisconnected; - handler?.Invoke(this, new ConnectionEventArgs { ClientId = client.Id, RemoteEndPoint = client.RemoteEndPoint, Nickname = client.Nickname }); + InvokePlugins(p => p.OnClientDisconnected(client, reason, exception)); } catch (Exception ex) { - var handler = OnGeneralError; - handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error disconnecting client", Nickname = client.Nickname }); + OnGeneralError?.Invoke(this, new ErrorEventArgs + { + ClientId = clientId, + Nickname = client.Nickname, + Exception = ex, + Message = "Error disconnecting client" + }); + } + }); + } + + public List GetClient(string clientId) + { + var result = new HashSet(); + + if (Guid.TryParse(clientId, out _)) + { + if (_clients.TryGetValue(clientId, out var client)) + { + result.Add(client); } } - } - public void JoinRoom(string clientId, string roomName) - { - var bag = _rooms.GetOrAdd(roomName, _ => new ConcurrentBag()); - bag.Add(clientId); - } - - public void LeaveRoom(string clientId, string roomName) - { - if (_rooms.TryGetValue(roomName, out var bag)) + string[] parts = clientId.Split(':'); + if (parts.Length == 2 && + IPAddress.TryParse(parts[0], out IPAddress ip) && + int.TryParse(parts[1], out int port)) { - var newBag = new ConcurrentBag(bag.Where(id => id != clientId)); - _rooms[roomName] = newBag; - } - } + var endPoint = new IPEndPoint(ip, port); + string clientKey = endPoint.ToString(); - public async Task BroadcastToNicknameAsync(string nickname, byte[] data) - { - var tasks = _clients.Values - .Where(c => !string.IsNullOrEmpty(c.Nickname) && c.Nickname.Equals(nickname, StringComparison.OrdinalIgnoreCase)) - .Select(c => SendDataAsync(c, data)) - .ToArray(); - await Task.WhenAll(tasks); - } - - public async Task BroadcastToNicknameAsync(string nickname, string message) - { - await BroadcastToNicknameAsync(nickname, Encoding.UTF8.GetBytes(message)); - } - - public async Task BroadcastToRoomAsync(string roomName, byte[] data) - { - if (!_rooms.TryGetValue(roomName, out var clients)) - { - return; + if (_clients.TryGetValue(clientKey, out var client)) + { + result.Add(client); + } } - var tasks = clients.Where(id => _clients.ContainsKey(id)) - .Select(id => SendDataAsync(_clients[id], data)) - .ToArray(); - await Task.WhenAll(tasks); - } - - public async Task BroadcastToRoomExceptAsync(string roomName, byte[] data, string exceptClientId) - { - if (!_rooms.TryGetValue(roomName, out var clients)) + foreach (var kvp in _clients) { - return; + if (kvp.Value.Nickname != null && + kvp.Value.Nickname.Equals(clientId, StringComparison.OrdinalIgnoreCase)) + { + result.Add(kvp.Value); + } } - var tasks = clients - .Where(id => _clients.ContainsKey(id) && id != exceptClientId) - .Select(id => SendDataAsync(_clients[id], data)) - .ToArray(); - - await Task.WhenAll(tasks); - } - - private readonly ConcurrentDictionary> _commands = new(); - - public void RegisterCommand(string command, Func handler) - { - _commands[command] = handler; - } - - private async Task HandleCommand(Connection client, string commandLine) - { - if (string.IsNullOrWhiteSpace(commandLine)) - { - return; - } - - var parts = commandLine.Split(' '); - var cmd = parts[0].ToUpperInvariant(); - var args = parts.Length > 1 ? parts[1] : string.Empty; - - if (_commands.TryGetValue(cmd, out var handler)) - { - await handler(client, args); - } - } - - - public async Task BroadcastToRoomAsync(string roomName, string message) - { - await BroadcastToRoomAsync(roomName, Encoding.UTF8.GetBytes(message)); + return result.ToList(); } public void Stop() { - lock (_serverLock) - { - _serverCancellation?.Cancel(); - _tcpListener?.Stop(); - _udpListener?.Close(); - } + _serverCancellation?.Cancel(); + _tcpListener?.Stop(); + _udpListener?.Close(); - foreach (var clientId in _clients.Keys.ToArray()) - { - DisconnectClient(clientId); - } + var disconnectTasks = _clients.Keys.ToArray() + .Select(id => DisconnectClientAsync(id, DisconnectReason.ServerShutdown)) + .ToList(); + + Task.WaitAll(disconnectTasks.ToArray()); + + InvokePlugins(p => p.OnServerStopped(this)); } - private void CleanupClientFromRooms(string clientId) + public void Dispose() { - foreach (var room in _rooms.Keys.ToList()) - { - LeaveRoom(clientId, room); - } + Stop(); + _serverCancellation?.Dispose(); } - - public void Dispose() => Stop(); } } diff --git a/EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs b/EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs new file mode 100644 index 0000000..c2385c9 --- /dev/null +++ b/EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs @@ -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); + } + } +} diff --git a/EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs b/EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs new file mode 100644 index 0000000..cc52176 --- /dev/null +++ b/EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs @@ -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) { } + } +} diff --git a/EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs b/EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs new file mode 100644 index 0000000..4a899bf --- /dev/null +++ b/EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs @@ -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) { } + } +} diff --git a/EonaCat.Connections/Plugins/Server/MetricsPlugin.cs b/EonaCat.Connections/Plugins/Server/MetricsPlugin.cs new file mode 100644 index 0000000..ae43d83 --- /dev/null +++ b/EonaCat.Connections/Plugins/Server/MetricsPlugin.cs @@ -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) { } + } +} diff --git a/EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs b/EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs new file mode 100644 index 0000000..4ddcac8 --- /dev/null +++ b/EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs @@ -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> _messageTimestamps; + + public RateLimiterPlugin(int maxMessages, TimeSpan interval) + { + _maxMessages = maxMessages; + _interval = interval; + _messageTimestamps = new ConcurrentDictionary>(); + } + + public void OnServerStarted(NetworkServer server) { } + public void OnServerStopped(NetworkServer server) { } + + public void OnClientConnected(Connection client) + { + _messageTimestamps[client.Id] = new ConcurrentQueue(); + } + + public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) + { + _messageTimestamps.TryRemove(client.Id, out _); + } + + public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) + { + if (!_messageTimestamps.TryGetValue(client.Id, out var queue)) return; + + var now = DateTime.UtcNow; + queue.Enqueue(now); + + // Remove old timestamps + while (queue.TryPeek(out var oldest) && now - oldest > _interval) + queue.TryDequeue(out _); + + if (queue.Count > _maxMessages) + { + Console.WriteLine($"[{Name}] Client {client.RemoteEndPoint} exceeded rate limit. Disconnecting..."); + + // Force disconnect + client.TcpClient?.Close(); + } + } + } +} diff --git a/EonaCat.Connections/Processors/JsonDataProcessor.cs b/EonaCat.Connections/Processors/JsonDataProcessor.cs index b596fb1..f9b5286 100644 --- a/EonaCat.Connections/Processors/JsonDataProcessor.cs +++ b/EonaCat.Connections/Processors/JsonDataProcessor.cs @@ -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. + /// /// Processes incoming data streams into JSON or text messages per client buffer. ///