diff --git a/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj b/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj index f01369b..c448043 100644 --- a/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj +++ b/EonaCat.Connections.Client/EonaCat.Connections.Client.csproj @@ -11,4 +11,10 @@ + + + PreserveNewest + + + diff --git a/EonaCat.Connections.Client/Program.cs b/EonaCat.Connections.Client/Program.cs index 5b79ec3..6f6100b 100644 --- a/EonaCat.Connections.Client/Program.cs +++ b/EonaCat.Connections.Client/Program.cs @@ -44,10 +44,10 @@ namespace EonaCat.Connections.Client.Example Protocol = ProtocolType.TCP, Host = "127.0.0.1", Port = 1111, - UseSsl = false, + UseSsl = true, UseAesEncryption = true, AesPassword = "EonaCat.Connections.Password", - //Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("client.pfx", "p@ss"), + Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("client.pfx", "p@ss"), }; _client = new NetworkClient(config); @@ -60,8 +60,8 @@ namespace EonaCat.Connections.Client.Example { Console.WriteLine($"Connected to server at {e.RemoteEndPoint}"); - // Send nickname - await _client.SendNicknameAsync("TestUser"); + // Set nickname + await _client.SetNicknameAsync("TestUser"); // Send a message await _client.SendAsync("Hello server!"); diff --git a/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj b/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj index f01369b..9dfcd2b 100644 --- a/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj +++ b/EonaCat.Connections.Server/EonaCat.Connections.Server.csproj @@ -11,4 +11,10 @@ + + + PreserveNewest + + + diff --git a/EonaCat.Connections.Server/Program.cs b/EonaCat.Connections.Server/Program.cs index 57efa1c..f2a87b2 100644 --- a/EonaCat.Connections.Server/Program.cs +++ b/EonaCat.Connections.Server/Program.cs @@ -38,11 +38,11 @@ namespace EonaCat.Connections.Server.Example { Protocol = ProtocolType.TCP, Port = 1111, - UseSsl = false, + UseSsl = true, UseAesEncryption = true, MaxConnections = 100000, AesPassword = "EonaCat.Connections.Password", - //Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("server.pfx", "p@ss") + Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("server.pfx", "p@ss") }; _server = new NetworkServer(config); diff --git a/EonaCat.Connections/EventArguments/ErrorEventArgs.cs b/EonaCat.Connections/EventArguments/ErrorEventArgs.cs index ad86f07..9afc4b6 100644 --- a/EonaCat.Connections/EventArguments/ErrorEventArgs.cs +++ b/EonaCat.Connections/EventArguments/ErrorEventArgs.cs @@ -6,6 +6,7 @@ public class ErrorEventArgs : EventArgs { public string ClientId { get; set; } + public string Nickname { get; set; } public Exception Exception { get; set; } public string Message { get; set; } public DateTime Timestamp { get; set; } = DateTime.UtcNow; diff --git a/EonaCat.Connections/Models/Connection.cs b/EonaCat.Connections/Models/Connection.cs index 51f1625..1e8fdd6 100644 --- a/EonaCat.Connections/Models/Connection.cs +++ b/EonaCat.Connections/Models/Connection.cs @@ -48,5 +48,6 @@ namespace EonaCat.Connections.Models public CancellationTokenSource CancellationToken { get; set; } public long BytesSent { get; set; } public long BytesReceived { get; set; } + public SemaphoreSlim SendLock { get; internal set; } } } \ No newline at end of file diff --git a/EonaCat.Connections/NetworkClient.cs b/EonaCat.Connections/NetworkClient.cs index d2cebd6..afb8480 100644 --- a/EonaCat.Connections/NetworkClient.cs +++ b/EonaCat.Connections/NetworkClient.cs @@ -11,9 +11,6 @@ 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; @@ -24,7 +21,25 @@ namespace EonaCat.Connections private CancellationTokenSource _cancellation; private bool _isConnected; - public bool IsConnected => _isConnected; + private readonly object _stateLock = new object(); + private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); + + private readonly HashSet _joinedRooms = new(); + + public bool IsConnected + { + get { lock (_stateLock) + { + return _isConnected; + } + } + private set { lock (_stateLock) + { + _isConnected = value; + } + } + } + public bool IsAutoReconnecting { get; private set; } public event EventHandler OnConnected; @@ -41,8 +56,11 @@ namespace EonaCat.Connections public async Task ConnectAsync() { - _cancellation?.Cancel(); - _cancellation = new CancellationTokenSource(); + lock (_stateLock) + { + _cancellation?.Cancel(); + _cancellation = new CancellationTokenSource(); + } if (_config.Protocol == ProtocolType.TCP) { @@ -58,10 +76,10 @@ namespace EonaCat.Connections { try { - _tcpClient = new TcpClient(); - await _tcpClient.ConnectAsync(_config.Host, _config.Port); + var client = new TcpClient(); + await client.ConnectAsync(_config.Host, _config.Port); - Stream stream = _tcpClient.GetStream(); + Stream stream = client.GetStream(); if (_config.UseSsl) { @@ -99,8 +117,12 @@ namespace EonaCat.Connections } } - _stream = stream; - _isConnected = true; + lock (_stateLock) + { + _tcpClient = client; + _stream = stream; + IsConnected = true; + } OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) }); @@ -108,7 +130,7 @@ namespace EonaCat.Connections } catch (Exception ex) { - _isConnected = false; + IsConnected = false; OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Failed to connect" }); _ = Task.Run(() => AutoReconnectAsync()); } @@ -118,9 +140,14 @@ namespace EonaCat.Connections { try { - _udpClient = new UdpClient(); - _udpClient.Connect(_config.Host, _config.Port); - _isConnected = true; + var client = new UdpClient(); + client.Connect(_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) }); @@ -128,14 +155,14 @@ namespace EonaCat.Connections } catch (Exception ex) { - _isConnected = false; + IsConnected = false; OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Failed to connect UDP" }); } } private async Task ReceiveDataAsync(CancellationToken ct) { - while (!ct.IsCancellationRequested && _isConnected) + while (!ct.IsCancellationRequested && IsConnected) { try { @@ -182,7 +209,7 @@ namespace EonaCat.Connections } catch (Exception ex) { - _isConnected = false; + IsConnected = false; OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving data" }); _ = Task.Run(() => AutoReconnectAsync()); break; @@ -210,7 +237,7 @@ namespace EonaCat.Connections private async Task ReceiveUdpDataAsync(CancellationToken ct) { - while (!ct.IsCancellationRequested && _isConnected) + while (!ct.IsCancellationRequested && IsConnected) { try { @@ -220,7 +247,7 @@ namespace EonaCat.Connections catch (Exception ex) { OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving UDP data" }); - _isConnected = false; + IsConnected = false; _ = Task.Run(() => AutoReconnectAsync()); break; } @@ -258,11 +285,12 @@ namespace EonaCat.Connections public async Task SendAsync(byte[] data) { - if (!_isConnected) + if (!IsConnected) { return; } + await _sendLock.WaitAsync(); try { if (_config.UseAesEncryption && _aesEncryption != null) @@ -296,10 +324,52 @@ namespace EonaCat.Connections var handler = _config.UseAesEncryption ? OnEncryptionError : OnGeneralError; handler?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error sending data" }); } + finally + { + _sendLock.Release(); + } + } + + /// Join a room (server should recognize this command) + public async Task JoinRoomAsync(string roomName) + { + if (string.IsNullOrWhiteSpace(roomName) || _joinedRooms.Contains(roomName)) + { + return; + } + + _joinedRooms.Add(roomName); + await SendAsync($"JOIN_ROOM:{roomName}"); + } + + public async Task LeaveRoomAsync(string roomName) + { + if (string.IsNullOrWhiteSpace(roomName) || !_joinedRooms.Contains(roomName)) + { + return; + } + + _joinedRooms.Remove(roomName); + await SendAsync($"LEAVE_ROOM:{roomName}"); + } + + 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)); - public async Task SendNicknameAsync(string nickname) => await SendAsync($"NICKNAME:{nickname}"); + private async Task SendNicknameAsync(string nickname) => await SendAsync($"NICKNAME:{nickname}"); private async Task AutoReconnectAsync() { @@ -309,18 +379,17 @@ namespace EonaCat.Connections } int attempt = 0; + IsAutoReconnecting = true; - while (!_isConnected && (_config.MaxReconnectAttempts == 0 || attempt < _config.MaxReconnectAttempts)) + while (!IsConnected && (_config.MaxReconnectAttempts == 0 || attempt < _config.MaxReconnectAttempts)) { attempt++; try { - IsAutoReconnecting = true; OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnecting attempt {attempt}" }); await ConnectAsync(); - if (_isConnected) + if (IsConnected) { - IsAutoReconnecting = false; OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnected after {attempt} attempt(s)" }); break; } @@ -330,22 +399,43 @@ namespace EonaCat.Connections 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) + { + _nickname = nickname; + await SendNicknameAsync(nickname); + } + + public string Nickname => _nickname; + + public async Task DisconnectAsync() { - _isConnected = false; - _cancellation?.Cancel(); + lock (_stateLock) + { + if (!IsConnected) + { + return; + } + + IsConnected = false; + _cancellation?.Cancel(); + } _tcpClient?.Close(); _udpClient?.Close(); _stream?.Dispose(); _aesEncryption?.Dispose(); - + _joinedRooms?.Clear(); + OnDisconnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self" }); } @@ -354,6 +444,7 @@ namespace EonaCat.Connections _cancellation?.Cancel(); DisconnectAsync().Wait(); _cancellation?.Dispose(); + _sendLock.Dispose(); } } } diff --git a/EonaCat.Connections/NetworkServer.cs b/EonaCat.Connections/NetworkServer.cs index 15d7db4..b104676 100644 --- a/EonaCat.Connections/NetworkServer.cs +++ b/EonaCat.Connections/NetworkServer.cs @@ -12,9 +12,6 @@ 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 NetworkServer : IDisposable { private readonly Configuration _config; @@ -24,6 +21,13 @@ 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; public event EventHandler OnConnected; public event EventHandler OnConnectedWithNickname; @@ -54,23 +58,47 @@ namespace EonaCat.Connections public async Task StartAsync() { - _serverCancellation?.Cancel(); - _serverCancellation = new CancellationTokenSource(); + lock (_serverLock) + { + if (_serverCancellation != null && !_serverCancellation.IsCancellationRequested) + { + // Server is already running + return; + } - if (_config.Protocol == ProtocolType.TCP) - { - await StartTcpServerAsync(); + _serverCancellation = new CancellationTokenSource(); } - else + + try { - await StartUdpServerAsync(); + if (_config.Protocol == ProtocolType.TCP) + { + await StartTcpServerAsync(); + } + else + { + await StartUdpServerAsync(); + } + } + catch (Exception ex) + { + OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error starting server" }); } } private async Task StartTcpServerAsync() { - _tcpListener = new TcpListener(IPAddress.Parse(_config.Host), _config.Port); - _tcpListener.Start(); + lock (_serverLock) + { + if (_tcpListener != null) + { + _tcpListener.Stop(); + } + + _tcpListener = new TcpListener(IPAddress.Parse(_config.Host), _config.Port); + _tcpListener.Start(); + } + Console.WriteLine($"TCP Server started on {_config.Host}:{_config.Port}"); while (!_serverCancellation.Token.IsCancellationRequested) @@ -88,10 +116,54 @@ namespace EonaCat.Connections } } + + 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)) + { + DisconnectClient(client.Id); + } + } + 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) + { + record = (0, now); + } + + record.Count++; + _rateLimits[clientId] = record; + + return record.Count <= _maxMessagesPerSecond; + } + + + private async Task StartUdpServerAsync() { - _udpListener = new UdpClient(_config.Port); + lock (_serverLock) + { + _udpListener?.Close(); + _udpListener = new UdpClient(_config.Port); + } + Console.WriteLine($"UDP Server started on {_config.Host}:{_config.Port}"); + _ = Task.Run(() => CleanupInactiveUdpClientsAsync(), _serverCancellation.Token); while (!_serverCancellation.Token.IsCancellationRequested) { @@ -100,7 +172,10 @@ namespace EonaCat.Connections var result = await _udpListener.ReceiveAsync(); _ = Task.Run(() => HandleUdpDataAsync(result), _serverCancellation.Token); } - catch (ObjectDisposedException) { break; } + catch (ObjectDisposedException) + { + break; + } catch (Exception ex) { OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving UDP data" }); @@ -108,6 +183,7 @@ namespace EonaCat.Connections } } + private async Task HandleTcpClientAsync(TcpClient tcpClient) { var clientId = Guid.NewGuid().ToString(); @@ -118,7 +194,8 @@ namespace EonaCat.Connections RemoteEndPoint = (IPEndPoint)tcpClient.Client.RemoteEndPoint, ConnectedAt = DateTime.UtcNow, LastActive = DateTime.UtcNow, - CancellationToken = new CancellationTokenSource() + CancellationToken = new CancellationTokenSource(), + SendLock = new SemaphoreSlim(1, 1) }; try @@ -147,7 +224,8 @@ namespace EonaCat.Connections } catch (Exception ex) { - OnSslError?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Exception = ex, Message = "SSL authentication failed" }); + var handler = OnSslError; + handler?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Exception = ex, Message = "SSL authentication failed" }); return; } } @@ -156,7 +234,6 @@ namespace EonaCat.Connections { try { - // Create AES object client.AesEncryption = Aes.Create(); client.AesEncryption.KeySize = 256; client.AesEncryption.BlockSize = 128; @@ -164,17 +241,12 @@ namespace EonaCat.Connections client.AesEncryption.Padding = PaddingMode.PKCS7; client.IsEncrypted = true; - // Send salt to client to derive key await AesKeyExchange.SendAesKeyAsync(stream, client.AesEncryption, _config.AesPassword); } catch (Exception ex) { - OnEncryptionError?.Invoke(this, new ErrorEventArgs - { - ClientId = clientId, - Exception = ex, - Message = "AES setup failed" - }); + var handler = OnEncryptionError; + handler?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Exception = ex, Message = "AES setup failed" }); return; } } @@ -184,13 +256,15 @@ namespace EonaCat.Connections lock (_statsLock) { _stats.TotalConnections++; } - OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientId, RemoteEndPoint = client.RemoteEndPoint }); + var connectedHandler = OnConnected; + connectedHandler?.Invoke(this, new ConnectionEventArgs { ClientId = clientId, RemoteEndPoint = client.RemoteEndPoint }); await HandleClientCommunicationAsync(client); } catch (Exception ex) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Exception = ex, Message = "Error handling TCP client" }); + var handler = OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs { ClientId = clientId, Exception = ex, Message = "Error handling TCP client" }); } finally { @@ -198,8 +272,6 @@ namespace EonaCat.Connections } } - - private async Task HandleUdpDataAsync(UdpReceiveResult result) { var clientKey = result.RemoteEndPoint.ToString(); @@ -209,11 +281,15 @@ namespace EonaCat.Connections { Id = clientKey, RemoteEndPoint = result.RemoteEndPoint, - ConnectedAt = DateTime.UtcNow + ConnectedAt = DateTime.UtcNow, + SendLock = new SemaphoreSlim(1, 1) }; _clients[clientKey] = client; + lock (_statsLock) { _stats.TotalConnections++; } - OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = clientKey, RemoteEndPoint = result.RemoteEndPoint }); + + var handler = OnConnected; + handler?.Invoke(this, new ConnectionEventArgs { ClientId = clientKey, RemoteEndPoint = result.RemoteEndPoint }); } await ProcessReceivedDataAsync(client, result.Buffer); @@ -269,7 +345,8 @@ namespace EonaCat.Connections } catch (Exception ex) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error reading from client" }); + var handler = OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error reading from client" }); break; } } @@ -295,6 +372,13 @@ namespace EonaCat.Connections { try { + if (!CheckRateLimit(client.Id)) + { + // Throttle the client + await Task.Delay(100); + return; + } + client.BytesReceived += data.Length; lock (_statsLock) { @@ -316,7 +400,13 @@ namespace EonaCat.Connections if (stringData.StartsWith("NICKNAME:")) { client.Nickname = stringData.Substring(9); - OnConnectedWithNickname?.Invoke(this, new ConnectionEventArgs { ClientId = client.Id, RemoteEndPoint = client.RemoteEndPoint, Nickname = client.Nickname }); + var handler = OnConnectedWithNickname; + handler?.Invoke(this, new ConnectionEventArgs + { + ClientId = client.Id, + RemoteEndPoint = client.RemoteEndPoint, + Nickname = client.Nickname + }); return; } else if (stringData.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase)) @@ -324,10 +414,61 @@ namespace EonaCat.Connections DisconnectClient(client.Id); 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; - OnDataReceived?.Invoke(this, new DataReceivedEventArgs + var dataHandler = OnDataReceived; + dataHandler?.Invoke(this, new DataReceivedEventArgs { ClientId = client.Id, Nickname = client.Nickname, @@ -340,20 +481,18 @@ namespace EonaCat.Connections catch (Exception ex) { var handler = client.IsEncrypted ? OnEncryptionError : OnGeneralError; - handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error processing data" }); + handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error processing data", Nickname = client.Nickname }); } } private async Task SendDataAsync(Connection client, byte[] data) { + await client.SendLock.WaitAsync(); try { - // Encrypt if AES is enabled if (client.IsEncrypted && client.AesEncryption != null) { data = await AesCryptoHelpers.EncryptDataAsync(data, client.AesEncryption); - - // Prepend 4-byte length (big-endian) for framing var lengthPrefix = BitConverter.GetBytes(data.Length); if (BitConverter.IsLittleEndian) { @@ -377,7 +516,6 @@ namespace EonaCat.Connections await _udpListener.SendAsync(data, data.Length, client.RemoteEndPoint); } - // Update stats client.BytesSent += data.Length; lock (_statsLock) { @@ -388,13 +526,86 @@ 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" - }); + handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error sending data", Nickname = client.Nickname }); } + finally + { + client.SendLock.Release(); + } + } + + public async Task SendFileAsync(Connection client, byte[] fileData, int chunkSize = 8192) + { + int offset = 0; + while (offset < fileData.Length) + { + 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) @@ -405,7 +616,6 @@ namespace EonaCat.Connections return; } - // Fallback: try nickname foreach (var kvp in _clients) { if (kvp.Value.Nickname != null && kvp.Value.Nickname.Equals(clientId, StringComparison.OrdinalIgnoreCase)) @@ -438,25 +648,127 @@ namespace EonaCat.Connections { try { + CleanupClientFromRooms(clientId); + client.CancellationToken?.Cancel(); client.TcpClient?.Close(); client.Stream?.Dispose(); client.AesEncryption?.Dispose(); - OnDisconnected?.Invoke(this, new ConnectionEventArgs { ClientId = client.Id, RemoteEndPoint = client.RemoteEndPoint, Nickname = client.Nickname }); + foreach (var room in _rooms.Keys.ToList()) + { + if (_rooms.TryGetValue(room, out var bag)) + { + _rooms[room] = new ConcurrentBag(bag.Where(id => id != clientId)); + } + } + + var handler = OnDisconnected; + handler?.Invoke(this, new ConnectionEventArgs { ClientId = client.Id, RemoteEndPoint = client.RemoteEndPoint, Nickname = client.Nickname }); } catch (Exception ex) { - OnGeneralError?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error disconnecting client" }); + var handler = OnGeneralError; + handler?.Invoke(this, new ErrorEventArgs { ClientId = client.Id, Exception = ex, Message = "Error disconnecting client", Nickname = client.Nickname }); } } } + 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)) + { + var newBag = new ConcurrentBag(bag.Where(id => id != clientId)); + _rooms[roomName] = newBag; + } + } + + 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; + } + + 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)) + { + return; + } + + 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)); + } + public void Stop() { - _serverCancellation?.Cancel(); - _tcpListener?.Stop(); - _udpListener?.Close(); + lock (_serverLock) + { + _serverCancellation?.Cancel(); + _tcpListener?.Stop(); + _udpListener?.Close(); + } foreach (var clientId in _clients.Keys.ToArray()) { @@ -464,6 +776,14 @@ namespace EonaCat.Connections } } + private void CleanupClientFromRooms(string clientId) + { + foreach (var room in _rooms.Keys.ToList()) + { + LeaveRoom(clientId, room); + } + } + public void Dispose() => Stop(); } } diff --git a/README.md b/README.md index d2ad8a9..ce68427 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,11 @@ servers and clients with optional TLS (for TCP) and optional application-layer e { Protocol = ProtocolType.TCP, Port = 1111, - UseSsl = false, + UseSsl = true, UseAesEncryption = true, MaxConnections = 100000, AesPassword = "EonaCat.Connections.Password", - //Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("server.pfx", "p@ss") + Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("server.pfx", "p@ss") }; _server = new NetworkServer(config); @@ -131,9 +131,7 @@ servers and clients with optional TLS (for TCP) and optional application-layer e ## Client example: - using EonaCat.Connections; - using EonaCat.Connections.Models; - using System.Text; + using EonaCat.Connections.Models; namespace EonaCat.Connections.Client.Example { @@ -150,8 +148,15 @@ servers and clients with optional TLS (for TCP) and optional application-layer e while (true) { + if (!_client.IsConnected) + { + await Task.Delay(1000).ConfigureAwait(false); + continue; + } + Console.Write("Enter message to send (or 'exit' to quit): "); var message = Console.ReadLine(); + if (!string.IsNullOrEmpty(message) && message.Equals("exit", StringComparison.OrdinalIgnoreCase)) { await _client.DisconnectAsync().ConfigureAwait(false); @@ -172,18 +177,27 @@ servers and clients with optional TLS (for TCP) and optional application-layer e Protocol = ProtocolType.TCP, Host = "127.0.0.1", Port = 1111, - UseSsl = false, + UseSsl = true, UseAesEncryption = true, AesPassword = "EonaCat.Connections.Password", - //Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("client.pfx", "p@ss"), + Certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2("client.pfx", "p@ss"), }; _client = new NetworkClient(config); + _client.OnGeneralError += (sender, e) => + Console.WriteLine($"Error: {e.Message}"); + // Subscribe to events - _client.OnConnected += (sender, e) => + _client.OnConnected += async (sender, e) => { Console.WriteLine($"Connected to server at {e.RemoteEndPoint}"); + + // Set nickname + await _client.SetNicknameAsync("TestUser"); + + // Send a message + await _client.SendAsync("Hello server!"); }; _client.OnDataReceived += (sender, e) => @@ -194,13 +208,8 @@ servers and clients with optional TLS (for TCP) and optional application-layer e Console.WriteLine("Disconnected from server"); }; + Console.WriteLine("Connecting to server..."); await _client.ConnectAsync(); - - // Send nickname - await _client.SendNicknameAsync("TestUser"); - - // Send a message - await _client.SendAsync("Hello server!"); } } -} \ No newline at end of file + } \ No newline at end of file