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.
///