Added rooms
This commit is contained in:
parent
06471a7674
commit
9eeabb9f24
|
@ -11,4 +11,10 @@
|
|||
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="client.pfx">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -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!");
|
||||
|
|
|
@ -11,4 +11,10 @@
|
|||
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="server.pfx">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
39
README.md
39
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!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue