Added rooms

This commit is contained in:
EonaCat 2025-08-27 20:13:23 +02:00
parent 06471a7674
commit 9eeabb9f24
9 changed files with 535 additions and 101 deletions

View File

@ -11,4 +11,10 @@
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="client.pfx">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -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!");

View File

@ -11,4 +11,10 @@
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="server.pfx">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -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);

View File

@ -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;

View File

@ -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; }
}
}

View File

@ -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<string> _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<ConnectionEventArgs> 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();
}
}
/// <summary>Join a room (server should recognize this command)</summary>
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<string> 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();
}
}
}

View File

@ -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<string, ConcurrentBag<string>> _rooms = new();
private readonly ConcurrentDictionary<string, ConcurrentQueue<string>> _roomHistory = new();
private readonly ConcurrentDictionary<string, string> _roomPasswords = new();
private readonly ConcurrentDictionary<string, (int Count, DateTime Timestamp)> _rateLimits = new();
private readonly int _maxMessagesPerSecond = 10;
public event EventHandler<ConnectionEventArgs> OnConnected;
public event EventHandler<ConnectionEventArgs> 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<string>());
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<string>(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<string>());
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<string>());
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<string> GetRoomHistory(string roomName)
{
if (_roomHistory.TryGetValue(roomName, out var queue))
{
return queue.ToArray();
}
return Enumerable.Empty<string>();
}
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<Connection> 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<string>(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<string>());
bag.Add(clientId);
}
public void LeaveRoom(string clientId, string roomName)
{
if (_rooms.TryGetValue(roomName, out var bag))
{
var newBag = new ConcurrentBag<string>(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<string, Func<Connection, string, Task>> _commands = new();
public void RegisterCommand(string command, Func<Connection, string, Task> 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();
}
}

View File

@ -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!");
}
}
}
}