Added plugin system
This commit is contained in:
@@ -61,7 +61,7 @@ namespace EonaCat.Connections.Client.Example
|
|||||||
Console.WriteLine($"Connected to server at {e.RemoteEndPoint}");
|
Console.WriteLine($"Connected to server at {e.RemoteEndPoint}");
|
||||||
|
|
||||||
// Set nickname
|
// Set nickname
|
||||||
await _client.SetNicknameAsync("TestUser");
|
await _client.SendNicknameAsync("TestUser");
|
||||||
|
|
||||||
// Send a message
|
// Send a message
|
||||||
await _client.SendAsync("Hello server!");
|
await _client.SendAsync("Hello server!");
|
||||||
|
23
EonaCat.Connections/DisconnectReason.cs
Normal file
23
EonaCat.Connections/DisconnectReason.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
public enum DisconnectReason
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
RemoteClosed,
|
||||||
|
LocalClosed,
|
||||||
|
Timeout,
|
||||||
|
Error,
|
||||||
|
ServerShutdown,
|
||||||
|
Reconnect,
|
||||||
|
ClientRequested,
|
||||||
|
Forced
|
||||||
|
}
|
||||||
|
}
|
@@ -11,7 +11,7 @@
|
|||||||
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||||
<PackageReadmeFile>readme.md</PackageReadmeFile>
|
<PackageReadmeFile>readme.md</PackageReadmeFile>
|
||||||
<PackageId>EonaCat.Connections</PackageId>
|
<PackageId>EonaCat.Connections</PackageId>
|
||||||
<Version>1.0.7</Version>
|
<Version>1.0.8</Version>
|
||||||
<Authors>EonaCat (Jeroen Saey)</Authors>
|
<Authors>EonaCat (Jeroen Saey)</Authors>
|
||||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||||
<PackageIcon>EonaCat.png</PackageIcon>
|
<PackageIcon>EonaCat.png</PackageIcon>
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="EonaCat.Json" Version="1.1.9" />
|
<PackageReference Include="EonaCat.Json" Version="1.1.9" />
|
||||||
|
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
namespace EonaCat.Connections.EventArguments
|
namespace EonaCat.Connections.EventArguments
|
||||||
{
|
{
|
||||||
@@ -9,8 +10,72 @@ namespace EonaCat.Connections.EventArguments
|
|||||||
{
|
{
|
||||||
public string ClientId { get; set; }
|
public string ClientId { get; set; }
|
||||||
public string Nickname { get; set; }
|
public string Nickname { get; set; }
|
||||||
public bool HasNickname => !string.IsNullOrEmpty(Nickname);
|
|
||||||
public IPEndPoint RemoteEndPoint { get; set; }
|
public IPEndPoint RemoteEndPoint { get; set; }
|
||||||
|
public DisconnectReason Reason { get; set; } = DisconnectReason.Unknown;
|
||||||
|
public Exception Exception { get; set; }
|
||||||
|
public bool HasException => Exception != null;
|
||||||
|
|
||||||
|
public bool IsLocalDisconnect =>
|
||||||
|
Reason == DisconnectReason.LocalClosed
|
||||||
|
|| Reason == DisconnectReason.Timeout
|
||||||
|
|| Reason == DisconnectReason.ServerShutdown
|
||||||
|
|| Reason == DisconnectReason.Reconnect
|
||||||
|
|| Reason == DisconnectReason.ClientRequested
|
||||||
|
|| Reason == DisconnectReason.Forced;
|
||||||
|
|
||||||
|
public bool IsRemoteDisconnect =>
|
||||||
|
Reason == DisconnectReason.RemoteClosed;
|
||||||
|
|
||||||
|
public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname);
|
||||||
|
public bool HasClientId => !string.IsNullOrWhiteSpace(ClientId);
|
||||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
public bool HasRemoteEndPoint => RemoteEndPoint != null;
|
||||||
|
public bool IsRemoteEndPointIPv4 => RemoteEndPoint?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork;
|
||||||
|
public bool HasRemoteEndPointIPv6 => RemoteEndPoint?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
||||||
|
public bool IsRemoteEndPointLoopback => RemoteEndPoint != null && IPAddress.IsLoopback(RemoteEndPoint.Address);
|
||||||
|
|
||||||
|
|
||||||
|
public static DisconnectReason Determine(DisconnectReason reason, Exception ex)
|
||||||
|
{
|
||||||
|
if (ex == null)
|
||||||
|
{
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ex is SocketException socketEx)
|
||||||
|
{
|
||||||
|
switch (socketEx.SocketErrorCode)
|
||||||
|
{
|
||||||
|
case SocketError.ConnectionReset:
|
||||||
|
case SocketError.Shutdown:
|
||||||
|
case SocketError.Disconnecting:
|
||||||
|
return DisconnectReason.RemoteClosed;
|
||||||
|
|
||||||
|
case SocketError.TimedOut:
|
||||||
|
return DisconnectReason.Timeout;
|
||||||
|
|
||||||
|
case SocketError.NetworkDown:
|
||||||
|
case SocketError.NetworkReset:
|
||||||
|
case SocketError.NetworkUnreachable:
|
||||||
|
return DisconnectReason.Error;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return DisconnectReason.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ex is ObjectDisposedException || ex is InvalidOperationException)
|
||||||
|
{
|
||||||
|
return DisconnectReason.LocalClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ex.Message.Contains("An existing connection was forcibly closed by the remote host")
|
||||||
|
|| ex.Message.Contains("The remote party has closed the transport stream"))
|
||||||
|
{
|
||||||
|
return DisconnectReason.RemoteClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DisconnectReason.Error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,105 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace EonaCat.Connections.Helpers
|
|
||||||
{
|
|
||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
|
||||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
|
||||||
|
|
||||||
public static class AesCryptoHelpers
|
|
||||||
{
|
|
||||||
private static readonly byte[] HmacInfo = Encoding.UTF8.GetBytes("EonaCat.Connections.HMAC");
|
|
||||||
|
|
||||||
public static async Task<byte[]> EncryptDataAsync(byte[] plaintext, Aes aes)
|
|
||||||
{
|
|
||||||
byte[] iv = new byte[aes.BlockSize / 8];
|
|
||||||
using (var rng = RandomNumberGenerator.Create())
|
|
||||||
{
|
|
||||||
rng.GetBytes(iv);
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] ciphertext;
|
|
||||||
using (var encryptor = aes.CreateEncryptor(aes.Key, iv))
|
|
||||||
using (var ms = new MemoryStream())
|
|
||||||
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
|
|
||||||
{
|
|
||||||
await cs.WriteAsync(plaintext, 0, plaintext.Length);
|
|
||||||
cs.FlushFinalBlock();
|
|
||||||
ciphertext = ms.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] hmacKey = DeriveHmacKey(aes.Key);
|
|
||||||
byte[] toAuth = iv.Concat(ciphertext).ToArray();
|
|
||||||
byte[] hmac;
|
|
||||||
using (var h = new HMACSHA256(hmacKey))
|
|
||||||
{
|
|
||||||
hmac = h.ComputeHash(toAuth);
|
|
||||||
}
|
|
||||||
|
|
||||||
return toAuth.Concat(hmac).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<byte[]> DecryptDataAsync(byte[] payload, Aes aes)
|
|
||||||
{
|
|
||||||
int ivLen = aes.BlockSize / 8;
|
|
||||||
int hmacLen = 32;
|
|
||||||
|
|
||||||
if (payload.Length < ivLen + hmacLen)
|
|
||||||
{
|
|
||||||
throw new CryptographicException("Payload too short");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] iv = payload.Take(ivLen).ToArray();
|
|
||||||
byte[] ciphertext = payload.Skip(ivLen).Take(payload.Length - ivLen - hmacLen).ToArray();
|
|
||||||
byte[] receivedHmac = payload.Skip(payload.Length - hmacLen).ToArray();
|
|
||||||
|
|
||||||
byte[] hmacKey = DeriveHmacKey(aes.Key);
|
|
||||||
byte[] toAuth = iv.Concat(ciphertext).ToArray();
|
|
||||||
byte[] computed;
|
|
||||||
using (var h = new HMACSHA256(hmacKey))
|
|
||||||
{
|
|
||||||
computed = h.ComputeHash(toAuth);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!FixedTimeEquals(computed, receivedHmac))
|
|
||||||
{
|
|
||||||
throw new CryptographicException("HMAC validation failed: message tampered or wrong key");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] plaintext;
|
|
||||||
using (var decryptor = aes.CreateDecryptor(aes.Key, iv))
|
|
||||||
using (var ms = new MemoryStream(ciphertext))
|
|
||||||
using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read))
|
|
||||||
using (var result = new MemoryStream())
|
|
||||||
{
|
|
||||||
await cs.CopyToAsync(result);
|
|
||||||
plaintext = result.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
return plaintext;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] DeriveHmacKey(byte[] aesKey)
|
|
||||||
{
|
|
||||||
using var h = new HMACSHA256(aesKey);
|
|
||||||
return h.ComputeHash(HmacInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool FixedTimeEquals(byte[] a, byte[] b)
|
|
||||||
{
|
|
||||||
if (a.Length != b.Length)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int diff = 0;
|
|
||||||
for (int i = 0; i < a.Length; i++)
|
|
||||||
{
|
|
||||||
diff |= a[i] ^ b[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
return diff == 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace EonaCat.Connections.Helpers
|
namespace EonaCat.Connections.Helpers
|
||||||
{
|
{
|
||||||
@@ -7,72 +8,237 @@ namespace EonaCat.Connections.Helpers
|
|||||||
|
|
||||||
public static class AesKeyExchange
|
public static class AesKeyExchange
|
||||||
{
|
{
|
||||||
private const int _saltSize = 16;
|
// 256-bit salt
|
||||||
private const int _keySize = 32;
|
private const int _saltSize = 32;
|
||||||
|
|
||||||
|
// 128-bit IV
|
||||||
private const int _ivSize = 16;
|
private const int _ivSize = 16;
|
||||||
private const int _hmacSize = 32;
|
|
||||||
private const int _pbkdf2Iterations = 100_000;
|
|
||||||
|
|
||||||
// Returns an AES object derived from the password and salt
|
// 256-bit AES key
|
||||||
public static async Task<Aes> ReceiveAesKeyAsync(Stream stream, string password)
|
private const int _aesKeySize = 32;
|
||||||
{
|
|
||||||
// Read salt
|
|
||||||
byte[] salt = new byte[_saltSize];
|
|
||||||
await stream.ReadExactlyAsync(salt, 0, _saltSize);
|
|
||||||
|
|
||||||
// Derive key
|
// 256-bit HMAC key (key confirmation)
|
||||||
byte[] key;
|
private const int _hmacKeySize = 32;
|
||||||
using (var kdf = new Rfc2898DeriveBytes(password, salt, _pbkdf2Iterations, HashAlgorithmName.SHA256))
|
|
||||||
|
// PBKDF2 iterations
|
||||||
|
private const int _iterations = 800_000;
|
||||||
|
|
||||||
|
private static readonly byte[] KeyConfirmationLabel = Encoding.UTF8.GetBytes("KEYCONFIRMATION");
|
||||||
|
|
||||||
|
public static async Task<byte[]> EncryptDataAsync(byte[] data, Aes aes)
|
||||||
{
|
{
|
||||||
key = kdf.GetBytes(_keySize);
|
using (var encryptor = aes.CreateEncryptor())
|
||||||
|
using (var ms = new MemoryStream())
|
||||||
|
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
|
||||||
|
{
|
||||||
|
await cs.WriteAsync(data, 0, data.Length);
|
||||||
|
cs.FlushFinalBlock();
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var aes = Aes.Create();
|
public static async Task<byte[]> DecryptDataAsync(byte[] data, Aes aes)
|
||||||
|
{
|
||||||
|
using (var decryptor = aes.CreateDecryptor())
|
||||||
|
using (var ms = new MemoryStream(data))
|
||||||
|
using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read))
|
||||||
|
using (var result = new MemoryStream())
|
||||||
|
{
|
||||||
|
await cs.CopyToAsync(result);
|
||||||
|
return result.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Aes> SendAesKeyAsync(Stream stream, Aes aes, string password)
|
||||||
|
{
|
||||||
|
if (stream == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aes == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(aes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Password/PSK required", nameof(password));
|
||||||
|
}
|
||||||
|
|
||||||
|
var salt = RandomBytes(_saltSize);
|
||||||
|
var iv = RandomBytes(_ivSize);
|
||||||
|
|
||||||
|
// Derive AES key and HMAC key (for key confirmation)
|
||||||
|
var keyMaterial = DeriveKey(password, salt, _aesKeySize + _hmacKeySize);
|
||||||
|
var aesKey = new byte[_aesKeySize];
|
||||||
|
var hmacKey = new byte[_hmacKeySize];
|
||||||
|
Buffer.BlockCopy(keyMaterial, 0, aesKey, 0, _aesKeySize);
|
||||||
|
Buffer.BlockCopy(keyMaterial, _aesKeySize, hmacKey, 0, _hmacKeySize);
|
||||||
|
|
||||||
|
// Compute key confirmation HMAC = HMAC(hmacKey, "KEYCONFIRM" || salt || iv)
|
||||||
|
byte[] keyConfirm;
|
||||||
|
using (var h = new HMACSHA256(hmacKey))
|
||||||
|
{
|
||||||
|
h.TransformBlock(KeyConfirmationLabel, 0, KeyConfirmationLabel.Length, null, 0);
|
||||||
|
h.TransformBlock(salt, 0, salt.Length, null, 0);
|
||||||
|
h.TransformFinalBlock(iv, 0, iv.Length);
|
||||||
|
keyConfirm = h.Hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send: salt, iv, keyConfirm (each length-prefixed 4-byte big-endian)
|
||||||
|
await WriteWithLengthAsync(stream, salt).ConfigureAwait(false);
|
||||||
|
await WriteWithLengthAsync(stream, iv).ConfigureAwait(false);
|
||||||
|
await WriteWithLengthAsync(stream, keyConfirm).ConfigureAwait(false);
|
||||||
|
await stream.FlushAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Configure AES and return
|
||||||
aes.KeySize = 256;
|
aes.KeySize = 256;
|
||||||
aes.BlockSize = 128;
|
|
||||||
aes.Mode = CipherMode.CBC;
|
aes.Mode = CipherMode.CBC;
|
||||||
aes.Padding = PaddingMode.PKCS7;
|
aes.Padding = PaddingMode.PKCS7;
|
||||||
aes.Key = key;
|
aes.Key = aesKey;
|
||||||
|
aes.IV = iv;
|
||||||
|
|
||||||
return aes;
|
return aes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sends salt (no key) to the other side
|
public static async Task<Aes> ReceiveAesKeyAsync(Stream stream, string password)
|
||||||
public static async Task SendAesKeyAsync(Stream stream, Aes aes, string password)
|
|
||||||
{
|
{
|
||||||
// Generate random salt
|
if (stream == null)
|
||||||
byte[] salt = new byte[_saltSize];
|
|
||||||
using (var rng = RandomNumberGenerator.Create())
|
|
||||||
{
|
{
|
||||||
rng.GetBytes(salt);
|
throw new ArgumentNullException(nameof(stream));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive AES key
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
byte[] key;
|
|
||||||
using (var kdf = new Rfc2898DeriveBytes(password, salt, _pbkdf2Iterations, HashAlgorithmName.SHA256))
|
|
||||||
{
|
{
|
||||||
key = kdf.GetBytes(_keySize);
|
throw new ArgumentException("Password/PSK required", nameof(password));
|
||||||
}
|
|
||||||
aes.Key = key;
|
|
||||||
|
|
||||||
// Send salt only
|
|
||||||
await stream.WriteAsync(salt, 0, salt.Length);
|
|
||||||
await stream.FlushAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task ReadExactlyAsync(this Stream stream, byte[] buffer, int offset, int count)
|
var salt = await ReadWithLengthAsync(stream).ConfigureAwait(false);
|
||||||
|
var iv = await ReadWithLengthAsync(stream).ConfigureAwait(false);
|
||||||
|
var keyConfirm = await ReadWithLengthAsync(stream).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (salt == null || salt.Length != _saltSize)
|
||||||
{
|
{
|
||||||
int read = 0;
|
throw new InvalidOperationException("Invalid salt length");
|
||||||
while (read < count)
|
|
||||||
{
|
|
||||||
int readBytes = await stream.ReadAsync(buffer, offset + read, count - read);
|
|
||||||
if (readBytes == 0)
|
|
||||||
{
|
|
||||||
throw new EndOfStreamException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
read += readBytes;
|
if (iv == null || iv.Length != _ivSize)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Invalid IV length");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var keyMaterial = DeriveKey(password, salt, _aesKeySize + _hmacKeySize);
|
||||||
|
var aesKey = new byte[_aesKeySize];
|
||||||
|
var hmacKey = new byte[_hmacKeySize];
|
||||||
|
Buffer.BlockCopy(keyMaterial, 0, aesKey, 0, _aesKeySize);
|
||||||
|
Buffer.BlockCopy(keyMaterial, _aesKeySize, hmacKey, 0, _hmacKeySize);
|
||||||
|
|
||||||
|
byte[] expected;
|
||||||
|
using (var h = new HMACSHA256(hmacKey))
|
||||||
|
{
|
||||||
|
h.TransformBlock(KeyConfirmationLabel, 0, KeyConfirmationLabel.Length, null, 0);
|
||||||
|
h.TransformBlock(salt, 0, salt.Length, null, 0);
|
||||||
|
h.TransformFinalBlock(iv, 0, iv.Length);
|
||||||
|
expected = h.Hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FixedTimeEquals(expected, keyConfirm))
|
||||||
|
{
|
||||||
|
throw new CryptographicException("Key confirmation failed - wrong password or tampered data");
|
||||||
|
}
|
||||||
|
|
||||||
|
var aes = Aes.Create();
|
||||||
|
aes.KeySize = 256;
|
||||||
|
aes.Mode = CipherMode.CBC;
|
||||||
|
aes.Padding = PaddingMode.PKCS7;
|
||||||
|
aes.Key = aesKey;
|
||||||
|
aes.IV = iv;
|
||||||
|
|
||||||
|
return aes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static async Task WriteWithLengthAsync(Stream stream, byte[] data)
|
||||||
|
{
|
||||||
|
var byteLength = BitConverter.GetBytes(data.Length);
|
||||||
|
if (BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
Array.Reverse(byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
await stream.WriteAsync(byteLength, 0, 4).ConfigureAwait(false);
|
||||||
|
await stream.WriteAsync(data, 0, data.Length).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> ReadWithLengthAsync(Stream stream)
|
||||||
|
{
|
||||||
|
var bufferLength = new byte[4];
|
||||||
|
await ReadExactlyAsync(stream, bufferLength, 0, 4).ConfigureAwait(false);
|
||||||
|
if (BitConverter.IsLittleEndian)
|
||||||
|
{
|
||||||
|
Array.Reverse(bufferLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
int length = BitConverter.ToInt32(bufferLength, 0);
|
||||||
|
if (length < 0 || length > 10_000_000)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Invalid length");
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = new byte[length];
|
||||||
|
await ReadExactlyAsync(stream, buffer, 0, length).ConfigureAwait(false);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, int offset, int count)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
while (total < count)
|
||||||
|
{
|
||||||
|
int read = await stream.ReadAsync(buffer, offset + total, count - total).ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
throw new EndOfStreamException("Stream ended prematurely");
|
||||||
|
}
|
||||||
|
|
||||||
|
total += read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DeriveKey(string password, byte[] salt, int size)
|
||||||
|
{
|
||||||
|
using (var pbkdf2 = new Rfc2898DeriveBytes(password, salt, _iterations, HashAlgorithmName.SHA256))
|
||||||
|
{
|
||||||
|
return pbkdf2.GetBytes(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] RandomBytes(int n)
|
||||||
|
{
|
||||||
|
var b = new byte[n];
|
||||||
|
using (var random = RandomNumberGenerator.Create())
|
||||||
|
{
|
||||||
|
random.GetBytes(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FixedTimeEquals(byte[] a, byte[] b)
|
||||||
|
{
|
||||||
|
if (a == null || b == null || a.Length != b.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int difference = 0;
|
||||||
|
for (int i = 0; i < a.Length; i++)
|
||||||
|
{
|
||||||
|
difference |= a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return difference == 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
17
EonaCat.Connections/IClientPlugin.cs
Normal file
17
EonaCat.Connections/IClientPlugin.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace EonaCat.Connections
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
|
public interface IClientPlugin
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
void OnClientStarted(NetworkClient client);
|
||||||
|
void OnClientConnected(NetworkClient client);
|
||||||
|
void OnClientDisconnected(NetworkClient client, DisconnectReason reason, Exception exception);
|
||||||
|
void OnDataReceived(NetworkClient client, byte[] data, string stringData, bool isBinary);
|
||||||
|
void OnError(NetworkClient client, Exception exception, string message);
|
||||||
|
void OnClientStopped(NetworkClient client);
|
||||||
|
}
|
||||||
|
}
|
55
EonaCat.Connections/IServerPlugin.cs
Normal file
55
EonaCat.Connections/IServerPlugin.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using EonaCat.Connections.Models;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the contract for plugins that extend the behavior of the NetworkServer.
|
||||||
|
/// Implement this interface to hook into server events such as
|
||||||
|
/// client connections, disconnections, message handling, and lifecycle events.
|
||||||
|
/// </summary>
|
||||||
|
public interface IServerPlugin
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique name of this plugin (used for logging/error reporting).
|
||||||
|
/// </summary>
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when the server has started successfully.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">The server instance that started.</param>
|
||||||
|
void OnServerStarted(NetworkServer server);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when the server has stopped.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">The server instance that stopped.</param>
|
||||||
|
void OnServerStopped(NetworkServer server);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when a client successfully connects.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The connected client.</param>
|
||||||
|
void OnClientConnected(Connection client);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when a client disconnects.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The client that disconnected.</param>
|
||||||
|
/// <param name="reason">The reason for disconnection.</param>
|
||||||
|
/// <param name="exception">Optional exception if the disconnect was caused by an error.</param>
|
||||||
|
void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when data is received from a client.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The client that sent the data.</param>
|
||||||
|
/// <param name="data">The raw bytes received.</param>
|
||||||
|
/// <param name="stringData">The decoded string (if text-based, otherwise null).</param>
|
||||||
|
/// <param name="isBinary">True if the message is binary data, false if text.</param>
|
||||||
|
void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary);
|
||||||
|
}
|
||||||
|
}
|
@@ -40,14 +40,26 @@ namespace EonaCat.Connections.Models
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id;
|
||||||
|
|
||||||
public DateTime ConnectedAt { get; set; }
|
public DateTime ConnectedAt { get; set; }
|
||||||
public DateTime LastActive { get; set; }
|
public DateTime LastActive { get; set; }
|
||||||
public bool IsSecure { get; set; }
|
public bool IsSecure { get; set; }
|
||||||
public bool IsEncrypted { get; set; }
|
public bool IsEncrypted { get; set; }
|
||||||
public Aes AesEncryption { get; set; }
|
public Aes AesEncryption { get; set; }
|
||||||
public CancellationTokenSource CancellationToken { get; set; }
|
public CancellationTokenSource CancellationToken { get; set; }
|
||||||
public long BytesSent { get; set; }
|
private long _bytesReceived;
|
||||||
public long BytesReceived { get; set; }
|
private long _bytesSent;
|
||||||
public SemaphoreSlim SendLock { get; internal set; }
|
public long BytesReceived => Interlocked.Read(ref _bytesReceived);
|
||||||
|
public long BytesSent => Interlocked.Read(ref _bytesSent);
|
||||||
|
|
||||||
|
public void AddBytesReceived(long count) => Interlocked.Add(ref _bytesReceived, count);
|
||||||
|
public void AddBytesSent(long count) => Interlocked.Add(ref _bytesSent, count);
|
||||||
|
|
||||||
|
public SemaphoreSlim SendLock { get; } = new SemaphoreSlim(1, 1);
|
||||||
|
public SemaphoreSlim ReadLock { get; } = new SemaphoreSlim(1, 1);
|
||||||
|
|
||||||
|
private int _disconnected;
|
||||||
|
public bool MarkDisconnected() => Interlocked.Exchange(ref _disconnected, 1) == 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -11,6 +11,9 @@ using ErrorEventArgs = EonaCat.Connections.EventArguments.ErrorEventArgs;
|
|||||||
|
|
||||||
namespace EonaCat.Connections
|
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
|
public class NetworkClient : IDisposable
|
||||||
{
|
{
|
||||||
private readonly Configuration _config;
|
private readonly Configuration _config;
|
||||||
@@ -21,27 +24,20 @@ namespace EonaCat.Connections
|
|||||||
private CancellationTokenSource _cancellation;
|
private CancellationTokenSource _cancellation;
|
||||||
private bool _isConnected;
|
private bool _isConnected;
|
||||||
|
|
||||||
private readonly object _stateLock = new object();
|
public bool IsConnected => _isConnected;
|
||||||
private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
|
public bool IsSecure => _config != null && (_config.UseSsl || _config.UseAesEncryption);
|
||||||
|
public bool IsEncrypted => _config != null && _config.UseAesEncryption;
|
||||||
|
public bool IsTcp => _config != null && _config.Protocol == ProtocolType.TCP;
|
||||||
|
|
||||||
private readonly HashSet<string> _joinedRooms = new();
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _connectLock = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _readLock = new(1, 1);
|
||||||
|
|
||||||
public bool IsConnected
|
public DateTime ConnectionTime { get; private set; }
|
||||||
{
|
public DateTime StartTime { get; set; }
|
||||||
get { lock (_stateLock)
|
public TimeSpan Uptime => DateTime.UtcNow - ConnectionTime;
|
||||||
{
|
|
||||||
return _isConnected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private set { lock (_stateLock)
|
|
||||||
{
|
|
||||||
_isConnected = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsAutoReconnecting { get; private set; }
|
|
||||||
|
|
||||||
|
private bool _disposed;
|
||||||
public event EventHandler<ConnectionEventArgs> OnConnected;
|
public event EventHandler<ConnectionEventArgs> OnConnected;
|
||||||
public event EventHandler<DataReceivedEventArgs> OnDataReceived;
|
public event EventHandler<DataReceivedEventArgs> OnDataReceived;
|
||||||
public event EventHandler<ConnectionEventArgs> OnDisconnected;
|
public event EventHandler<ConnectionEventArgs> OnDisconnected;
|
||||||
@@ -49,18 +45,19 @@ namespace EonaCat.Connections
|
|||||||
public event EventHandler<ErrorEventArgs> OnEncryptionError;
|
public event EventHandler<ErrorEventArgs> OnEncryptionError;
|
||||||
public event EventHandler<ErrorEventArgs> OnGeneralError;
|
public event EventHandler<ErrorEventArgs> OnGeneralError;
|
||||||
|
|
||||||
public string IpAddress => _config?.Host ?? string.Empty;
|
private readonly List<IClientPlugin> _plugins = new();
|
||||||
public int Port => _config?.Port ?? 0;
|
|
||||||
|
|
||||||
public NetworkClient(Configuration config) => _config = config;
|
public NetworkClient(Configuration config)
|
||||||
|
{
|
||||||
|
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ConnectAsync()
|
public async Task ConnectAsync()
|
||||||
{
|
{
|
||||||
lock (_stateLock)
|
await _connectLock.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
_cancellation?.Cancel();
|
|
||||||
_cancellation = new CancellationTokenSource();
|
_cancellation = new CancellationTokenSource();
|
||||||
}
|
|
||||||
|
|
||||||
if (_config.Protocol == ProtocolType.TCP)
|
if (_config.Protocol == ProtocolType.TCP)
|
||||||
{
|
{
|
||||||
@@ -71,30 +68,46 @@ namespace EonaCat.Connections
|
|||||||
await ConnectUdpAsync();
|
await ConnectUdpAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Connection error" });
|
||||||
|
NotifyError(ex, "General error");
|
||||||
|
if (_config.EnableAutoReconnect)
|
||||||
|
{
|
||||||
|
_ = Task.Run(() => AutoReconnectAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_connectLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ConnectTcpAsync()
|
private async Task ConnectTcpAsync()
|
||||||
{
|
{
|
||||||
try
|
_tcpClient = new TcpClient();
|
||||||
{
|
await _tcpClient.ConnectAsync(_config.Host, _config.Port);
|
||||||
var client = new TcpClient();
|
|
||||||
await client.ConnectAsync(_config.Host, _config.Port);
|
|
||||||
|
|
||||||
Stream stream = client.GetStream();
|
Stream stream = _tcpClient.GetStream();
|
||||||
|
|
||||||
|
// Setup SSL if required
|
||||||
if (_config.UseSsl)
|
if (_config.UseSsl)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sslStream = new SslStream(stream, false, _config.GetRemoteCertificateValidationCallback());
|
var sslStream = new SslStream(stream, false, userCertificateValidationCallback: _config.GetRemoteCertificateValidationCallback());
|
||||||
if (_config.Certificate != null)
|
if (_config.Certificate != null)
|
||||||
{
|
{
|
||||||
sslStream.AuthenticateAsClient(_config.Host, new X509CertificateCollection { _config.Certificate }, _config.CheckCertificateRevocation);
|
await sslStream.AuthenticateAsClientAsync(
|
||||||
|
_config.Host,
|
||||||
|
new X509CertificateCollection { _config.Certificate },
|
||||||
|
_config.CheckCertificateRevocation
|
||||||
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
sslStream.AuthenticateAsClient(_config.Host);
|
await sslStream.AuthenticateAsClientAsync(_config.Host);
|
||||||
}
|
}
|
||||||
|
|
||||||
stream = sslStream;
|
stream = sslStream;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -104,6 +117,7 @@ namespace EonaCat.Connections
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup AES encryption if required
|
||||||
if (_config.UseAesEncryption)
|
if (_config.UseAesEncryption)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -117,52 +131,86 @@ namespace EonaCat.Connections
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lock (_stateLock)
|
|
||||||
{
|
|
||||||
_tcpClient = client;
|
|
||||||
_stream = stream;
|
_stream = stream;
|
||||||
IsConnected = true;
|
_isConnected = true;
|
||||||
}
|
ConnectionTime = DateTime.UtcNow;
|
||||||
|
|
||||||
OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) });
|
OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) });
|
||||||
|
NotifyConnected();
|
||||||
|
|
||||||
_ = Task.Run(() => ReceiveDataAsync(_cancellation.Token), _cancellation.Token);
|
// Start receiving data
|
||||||
|
_ = Task.Run(() => ReceiveDataAsync(), _cancellation.Token);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
public void RegisterPlugin(IClientPlugin plugin)
|
||||||
{
|
{
|
||||||
IsConnected = false;
|
if (_plugins.Any(p => p.Name == plugin.Name))
|
||||||
OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Failed to connect" });
|
return;
|
||||||
_ = Task.Run(() => AutoReconnectAsync());
|
|
||||||
|
_plugins.Add(plugin);
|
||||||
|
plugin.OnClientStarted(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UnregisterPlugin(IClientPlugin plugin)
|
||||||
|
{
|
||||||
|
if (_plugins.Remove(plugin))
|
||||||
|
{
|
||||||
|
plugin.OnClientStopped(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void NotifyConnected()
|
||||||
|
{
|
||||||
|
foreach (var plugin in _plugins)
|
||||||
|
{
|
||||||
|
plugin.OnClientConnected(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyDisconnected(DisconnectReason reason, Exception exception)
|
||||||
|
{
|
||||||
|
foreach (var plugin in _plugins)
|
||||||
|
{
|
||||||
|
plugin.OnClientDisconnected(this, reason, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyData(byte[] data, string stringData, bool isBinary)
|
||||||
|
{
|
||||||
|
foreach (var plugin in _plugins)
|
||||||
|
{
|
||||||
|
plugin.OnDataReceived(this, data, stringData, isBinary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyError(Exception ex, string message)
|
||||||
|
{
|
||||||
|
foreach (var plugin in _plugins)
|
||||||
|
{
|
||||||
|
plugin.OnError(this, ex, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string IpAddress => _config != null ? _config.Host : string.Empty;
|
||||||
|
public int Port => _config != null ? _config.Port : 0;
|
||||||
|
|
||||||
|
public bool IsAutoReconnectRunning { get; private set; }
|
||||||
|
|
||||||
private async Task ConnectUdpAsync()
|
private async Task ConnectUdpAsync()
|
||||||
{
|
{
|
||||||
try
|
_udpClient = new UdpClient();
|
||||||
{
|
_udpClient.Connect(_config.Host, _config.Port);
|
||||||
var client = new UdpClient();
|
_isConnected = true;
|
||||||
client.Connect(_config.Host, _config.Port);
|
ConnectionTime = DateTime.UtcNow;
|
||||||
|
|
||||||
lock (_stateLock)
|
|
||||||
{
|
|
||||||
_udpClient = client;
|
|
||||||
IsConnected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) });
|
OnConnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self", RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port) });
|
||||||
|
|
||||||
_ = Task.Run(() => ReceiveUdpDataAsync(_cancellation.Token), _cancellation.Token);
|
// Start receiving data
|
||||||
}
|
_ = Task.Run(() => ReceiveUdpDataAsync(), _cancellation.Token);
|
||||||
catch (Exception ex)
|
await Task.CompletedTask;
|
||||||
{
|
|
||||||
IsConnected = false;
|
|
||||||
OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Failed to connect UDP" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ReceiveDataAsync(CancellationToken ct)
|
private async Task ReceiveDataAsync()
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested && IsConnected)
|
while (!_cancellation.Token.IsCancellationRequested && _isConnected)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -171,7 +219,8 @@ namespace EonaCat.Connections
|
|||||||
if (_config.UseAesEncryption && _aesEncryption != null)
|
if (_config.UseAesEncryption && _aesEncryption != null)
|
||||||
{
|
{
|
||||||
var lengthBuffer = new byte[4];
|
var lengthBuffer = new byte[4];
|
||||||
if (await ReadExactAsync(_stream, lengthBuffer, 4, ct) == 0)
|
int read = await ReadExactAsync(_stream, lengthBuffer, 4, _cancellation.Token).ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -182,19 +231,33 @@ namespace EonaCat.Connections
|
|||||||
}
|
}
|
||||||
|
|
||||||
int length = BitConverter.ToInt32(lengthBuffer, 0);
|
int length = BitConverter.ToInt32(lengthBuffer, 0);
|
||||||
|
if (length <= 0)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid packet length");
|
||||||
|
}
|
||||||
|
|
||||||
var encrypted = new byte[length];
|
var encrypted = new byte[length];
|
||||||
await ReadExactAsync(_stream, encrypted, length, ct);
|
await ReadExactAsync(_stream, encrypted, length, _cancellation.Token).ConfigureAwait(false);
|
||||||
|
data = await AesKeyExchange.DecryptDataAsync(encrypted, _aesEncryption).ConfigureAwait(false);
|
||||||
data = await AesCryptoHelpers.DecryptDataAsync(encrypted, _aesEncryption);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
data = new byte[_config.BufferSize];
|
data = new byte[_config.BufferSize];
|
||||||
int bytesRead = await _stream.ReadAsync(data, 0, data.Length, ct);
|
int bytesRead;
|
||||||
|
await _readLock.WaitAsync(_cancellation.Token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytesRead = await _stream.ReadAsync(data, 0, data.Length, _cancellation.Token);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_readLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
if (bytesRead == 0)
|
if (bytesRead == 0)
|
||||||
{
|
{
|
||||||
break;
|
await DisconnectAsync(DisconnectReason.RemoteClosed);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytesRead < data.Length)
|
if (bytesRead < data.Length)
|
||||||
@@ -207,12 +270,21 @@ namespace EonaCat.Connections
|
|||||||
|
|
||||||
await ProcessReceivedDataAsync(data);
|
await ProcessReceivedDataAsync(data);
|
||||||
}
|
}
|
||||||
|
catch (IOException ioEx)
|
||||||
|
{
|
||||||
|
await DisconnectAsync(DisconnectReason.RemoteClosed, ioEx);
|
||||||
|
}
|
||||||
|
catch (SocketException sockEx)
|
||||||
|
{
|
||||||
|
await DisconnectAsync(DisconnectReason.Error, sockEx);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
await DisconnectAsync(DisconnectReason.Timeout);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IsConnected = false;
|
await DisconnectAsync(DisconnectReason.Error, ex);
|
||||||
OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving data" });
|
|
||||||
_ = Task.Run(() => AutoReconnectAsync());
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +294,9 @@ namespace EonaCat.Connections
|
|||||||
private async Task<int> ReadExactAsync(Stream stream, byte[] buffer, int length, CancellationToken ct)
|
private async Task<int> ReadExactAsync(Stream stream, byte[] buffer, int length, CancellationToken ct)
|
||||||
{
|
{
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
|
await _readLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
while (offset < length)
|
while (offset < length)
|
||||||
{
|
{
|
||||||
int read = await stream.ReadAsync(buffer, offset, length - offset, ct);
|
int read = await stream.ReadAsync(buffer, offset, length - offset, ct);
|
||||||
@@ -229,15 +304,19 @@ namespace EonaCat.Connections
|
|||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
offset += read;
|
offset += read;
|
||||||
}
|
}
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
private async Task ReceiveUdpDataAsync(CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested && IsConnected)
|
_readLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReceiveUdpDataAsync()
|
||||||
|
{
|
||||||
|
while (!_cancellation.Token.IsCancellationRequested && _isConnected)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -246,8 +325,10 @@ namespace EonaCat.Connections
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving UDP data" });
|
OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error receiving data" });
|
||||||
IsConnected = false;
|
NotifyError(ex, "General error");
|
||||||
|
_isConnected = false;
|
||||||
|
ConnectionTime = DateTime.MinValue;
|
||||||
_ = Task.Run(() => AutoReconnectAsync());
|
_ = Task.Run(() => AutoReconnectAsync());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -258,15 +339,27 @@ namespace EonaCat.Connections
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string stringData = null;
|
|
||||||
bool isBinary = true;
|
bool isBinary = true;
|
||||||
|
string stringData = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
stringData = Encoding.UTF8.GetString(data);
|
stringData = Encoding.UTF8.GetString(data);
|
||||||
isBinary = Encoding.UTF8.GetBytes(stringData).Length != data.Length;
|
if (Encoding.UTF8.GetBytes(stringData).Length == data.Length)
|
||||||
|
{
|
||||||
|
isBinary = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep as binary
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBinary && stringData != null && stringData.Equals("DISCONNECT", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await DisconnectAsync(DisconnectReason.RemoteClosed);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
catch { }
|
|
||||||
|
|
||||||
OnDataReceived?.Invoke(this, new DataReceivedEventArgs
|
OnDataReceived?.Invoke(this, new DataReceivedEventArgs
|
||||||
{
|
{
|
||||||
@@ -275,17 +368,25 @@ namespace EonaCat.Connections
|
|||||||
StringData = stringData,
|
StringData = stringData,
|
||||||
IsBinary = isBinary
|
IsBinary = isBinary
|
||||||
});
|
});
|
||||||
|
NotifyData(data, stringData, isBinary);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var handler = _config.UseAesEncryption ? OnEncryptionError : OnGeneralError;
|
if (_config.UseAesEncryption)
|
||||||
handler?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error processing data" });
|
{
|
||||||
|
OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error processing data" });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error processing data" });
|
||||||
|
NotifyError(ex, "General error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendAsync(byte[] data)
|
public async Task SendAsync(byte[] data)
|
||||||
{
|
{
|
||||||
if (!IsConnected)
|
if (!_isConnected)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -295,7 +396,7 @@ namespace EonaCat.Connections
|
|||||||
{
|
{
|
||||||
if (_config.UseAesEncryption && _aesEncryption != null)
|
if (_config.UseAesEncryption && _aesEncryption != null)
|
||||||
{
|
{
|
||||||
data = await AesCryptoHelpers.EncryptDataAsync(data, _aesEncryption);
|
data = await AesKeyExchange.EncryptDataAsync(data, _aesEncryption);
|
||||||
|
|
||||||
var lengthPrefix = BitConverter.GetBytes(data.Length);
|
var lengthPrefix = BitConverter.GetBytes(data.Length);
|
||||||
if (BitConverter.IsLittleEndian)
|
if (BitConverter.IsLittleEndian)
|
||||||
@@ -321,8 +422,15 @@ namespace EonaCat.Connections
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var handler = _config.UseAesEncryption ? OnEncryptionError : OnGeneralError;
|
if (_config.UseAesEncryption)
|
||||||
handler?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error sending data" });
|
{
|
||||||
|
OnEncryptionError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error encrypting/sending data" });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OnGeneralError?.Invoke(this, new ErrorEventArgs { Exception = ex, Message = "Error sending data" });
|
||||||
|
NotifyError(ex, "General error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -330,121 +438,140 @@ namespace EonaCat.Connections
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Join a room (server should recognize this command)</summary>
|
public async Task SendAsync(string message)
|
||||||
public async Task JoinRoomAsync(string roomName)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(roomName) || _joinedRooms.Contains(roomName))
|
await SendAsync(Encoding.UTF8.GetBytes(message));
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_joinedRooms.Add(roomName);
|
public async Task SendNicknameAsync(string nickname)
|
||||||
await SendAsync($"JOIN_ROOM:{roomName}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LeaveRoomAsync(string roomName)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(roomName) || !_joinedRooms.Contains(roomName))
|
await SendAsync($"NICKNAME:{nickname}");
|
||||||
{
|
|
||||||
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));
|
|
||||||
private async Task SendNicknameAsync(string nickname) => await SendAsync($"NICKNAME:{nickname}");
|
|
||||||
|
|
||||||
private async Task AutoReconnectAsync()
|
private async Task AutoReconnectAsync()
|
||||||
{
|
{
|
||||||
if (!_config.EnableAutoReconnect || IsAutoReconnecting)
|
if (!_config.EnableAutoReconnect)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsAutoReconnectRunning)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int attempt = 0;
|
int attempt = 0;
|
||||||
IsAutoReconnecting = true;
|
|
||||||
|
|
||||||
while (!IsConnected && (_config.MaxReconnectAttempts == 0 || attempt < _config.MaxReconnectAttempts))
|
while (_config.EnableAutoReconnect && !_isConnected && (_config.MaxReconnectAttempts == 0 || attempt < _config.MaxReconnectAttempts))
|
||||||
{
|
{
|
||||||
attempt++;
|
attempt++;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnecting attempt {attempt}" });
|
OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Attempting to reconnect (Attempt {attempt})" });
|
||||||
|
IsAutoReconnectRunning = true;
|
||||||
await ConnectAsync();
|
await ConnectAsync();
|
||||||
if (IsConnected)
|
|
||||||
|
if (_isConnected)
|
||||||
{
|
{
|
||||||
OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnected after {attempt} attempt(s)" });
|
OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = $"Reconnected successfully after {attempt} attempt(s)" });
|
||||||
|
IsAutoReconnectRunning = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch
|
||||||
|
{
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Delay(_config.ReconnectDelayMs);
|
await Task.Delay(_config.ReconnectDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsConnected)
|
if (!_isConnected)
|
||||||
{
|
{
|
||||||
OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = "Failed to reconnect" });
|
OnGeneralError?.Invoke(this, new ErrorEventArgs { Message = "Failed to reconnect" });
|
||||||
}
|
}
|
||||||
|
|
||||||
IsAutoReconnecting = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string _nickname;
|
public async Task DisconnectAsync(
|
||||||
public async Task SetNicknameAsync(string nickname)
|
DisconnectReason reason = DisconnectReason.LocalClosed,
|
||||||
|
Exception exception = null,
|
||||||
|
bool forceDisconnection = false)
|
||||||
{
|
{
|
||||||
_nickname = nickname;
|
await _connectLock.WaitAsync();
|
||||||
await SendNicknameAsync(nickname);
|
try
|
||||||
}
|
|
||||||
|
|
||||||
public string Nickname => _nickname;
|
|
||||||
|
|
||||||
|
|
||||||
public async Task DisconnectAsync()
|
|
||||||
{
|
{
|
||||||
lock (_stateLock)
|
if (!_isConnected)
|
||||||
{
|
|
||||||
if (!IsConnected)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
IsConnected = false;
|
_isConnected = false;
|
||||||
_cancellation?.Cancel();
|
ConnectionTime = DateTime.MinValue;
|
||||||
}
|
|
||||||
|
|
||||||
|
_cancellation?.Cancel();
|
||||||
_tcpClient?.Close();
|
_tcpClient?.Close();
|
||||||
_udpClient?.Close();
|
_udpClient?.Close();
|
||||||
_stream?.Dispose();
|
_stream?.Dispose();
|
||||||
_aesEncryption?.Dispose();
|
_aesEncryption?.Dispose();
|
||||||
_joinedRooms?.Clear();
|
|
||||||
|
|
||||||
OnDisconnected?.Invoke(this, new ConnectionEventArgs { ClientId = "self" });
|
OnDisconnected?.Invoke(this, new ConnectionEventArgs
|
||||||
|
{
|
||||||
|
ClientId = "self",
|
||||||
|
RemoteEndPoint = new IPEndPoint(IPAddress.Parse(_config.Host), _config.Port),
|
||||||
|
Reason = ConnectionEventArgs.Determine(reason, exception),
|
||||||
|
Exception = exception
|
||||||
|
});
|
||||||
|
NotifyDisconnected(reason, exception);
|
||||||
|
|
||||||
|
if (!forceDisconnection && reason != DisconnectReason.Forced)
|
||||||
|
{
|
||||||
|
_ = Task.Run(() => AutoReconnectAsync());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("Auto-reconnect disabled due to forced disconnection.");
|
||||||
|
_config.EnableAutoReconnect = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_connectLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
await DisconnectAsync(forceDisconnection: true);
|
||||||
|
|
||||||
|
foreach (var plugin in _plugins.ToList())
|
||||||
|
{
|
||||||
|
plugin.OnClientStopped(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancellation?.Dispose();
|
||||||
|
_sendLock.Dispose();
|
||||||
|
_connectLock.Dispose();
|
||||||
|
_readLock.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_cancellation?.Cancel();
|
if (_disposed)
|
||||||
DisconnectAsync().Wait();
|
{
|
||||||
_cancellation?.Dispose();
|
return;
|
||||||
_sendLock.Dispose();
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
112
EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs
Normal file
112
EonaCat.Connections/Plugins/Client/ClientHttpMetricsPlugin.cs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
using EonaCat.Json;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.Plugins.Client
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
|
public class ClientHttpMetricsPlugin : IClientPlugin
|
||||||
|
{
|
||||||
|
public string Name => "ClientMetricsPlugin";
|
||||||
|
|
||||||
|
private NetworkClient _client;
|
||||||
|
private long _bytesSent;
|
||||||
|
private long _bytesReceived;
|
||||||
|
private long _messagesSent;
|
||||||
|
private long _messagesReceived;
|
||||||
|
|
||||||
|
private readonly int _httpPort;
|
||||||
|
private HttpListener _httpListener;
|
||||||
|
private CancellationTokenSource _cts;
|
||||||
|
|
||||||
|
public ClientHttpMetricsPlugin(int httpPort = 8080)
|
||||||
|
{
|
||||||
|
_httpPort = httpPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientStarted(NetworkClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
StartHttpServer(_cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientConnected(NetworkClient client)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{Name}] Connected to server at {client.IpAddress}:{client.Port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientDisconnected(NetworkClient client, DisconnectReason reason, Exception exception)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{Name}] Disconnected: {reason} {exception?.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnDataReceived(NetworkClient client, byte[] data, string stringData, bool isBinary)
|
||||||
|
{
|
||||||
|
_bytesReceived += data.Length;
|
||||||
|
_messagesReceived++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnError(NetworkClient client, Exception exception, string message)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{Name}] Error: {message} - {exception?.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientStopped(NetworkClient client)
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_httpListener?.Stop();
|
||||||
|
Console.WriteLine($"[{Name}] Plugin stopped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void IncrementSent(byte[] data)
|
||||||
|
{
|
||||||
|
_bytesSent += data.Length;
|
||||||
|
_messagesSent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartHttpServer(CancellationToken token)
|
||||||
|
{
|
||||||
|
_httpListener = new HttpListener();
|
||||||
|
_httpListener.Prefixes.Add($"http://*:{_httpPort}/metrics/");
|
||||||
|
_httpListener.Start();
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = await _httpListener.GetContextAsync();
|
||||||
|
var response = context.Response;
|
||||||
|
|
||||||
|
var metrics = new
|
||||||
|
{
|
||||||
|
IsConnected = _client.IsConnected,
|
||||||
|
Ip = _client.IpAddress,
|
||||||
|
Port = _client.Port,
|
||||||
|
Uptime = _client.Uptime.TotalSeconds,
|
||||||
|
BytesSent = _bytesSent,
|
||||||
|
BytesReceived = _bytesReceived,
|
||||||
|
MessagesSent = _messagesSent,
|
||||||
|
MessagesReceived = _messagesReceived
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonHelper.ToJson(metrics, Formatting.Indented);
|
||||||
|
var buffer = System.Text.Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
|
response.ContentType = "application/json";
|
||||||
|
response.ContentLength64 = buffer.Length;
|
||||||
|
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length, token);
|
||||||
|
response.Close();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
106
EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs
Normal file
106
EonaCat.Connections/Plugins/Server/HttpMetricsPlugin.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
using EonaCat.Connections.Models;
|
||||||
|
using EonaCat.Json;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.Plugins.Server
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
|
public class HttpMetricsPlugin : IServerPlugin
|
||||||
|
{
|
||||||
|
public string Name => "HttpMetricsPlugin";
|
||||||
|
|
||||||
|
private readonly int _port;
|
||||||
|
private HttpListener _httpListener;
|
||||||
|
private CancellationTokenSource _cts;
|
||||||
|
private NetworkServer _server;
|
||||||
|
|
||||||
|
public HttpMetricsPlugin(int port = 9100)
|
||||||
|
{
|
||||||
|
_port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnServerStarted(NetworkServer server)
|
||||||
|
{
|
||||||
|
_server = server;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_httpListener = new HttpListener();
|
||||||
|
_httpListener.Prefixes.Add($"http://*:{_port}/metrics/");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_httpListener.Start();
|
||||||
|
Console.WriteLine($"[{Name}] Metrics endpoint running at http://localhost:{_port}/metrics/");
|
||||||
|
}
|
||||||
|
catch (HttpListenerException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{Name}] Failed to start HTTP listener: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = await _httpListener.GetContextAsync();
|
||||||
|
|
||||||
|
if (context.Request.Url.AbsolutePath == "/metrics")
|
||||||
|
{
|
||||||
|
var stats = _server.GetStats();
|
||||||
|
|
||||||
|
var responseObj = new
|
||||||
|
{
|
||||||
|
uptime = stats.Uptime.ToString(),
|
||||||
|
startTime = stats.StartTime,
|
||||||
|
activeConnections = stats.ActiveConnections,
|
||||||
|
totalConnections = stats.TotalConnections,
|
||||||
|
bytesSent = stats.BytesSent,
|
||||||
|
bytesReceived = stats.BytesReceived,
|
||||||
|
messagesSent = stats.MessagesSent,
|
||||||
|
messagesReceived = stats.MessagesReceived,
|
||||||
|
messagesPerSecond = stats.MessagesPerSecond
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonHelper.ToJson(responseObj, Formatting.Indented);
|
||||||
|
var buffer = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
context.Response.StatusCode = 200;
|
||||||
|
await context.Response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||||||
|
context.Response.OutputStream.Close();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 404;
|
||||||
|
context.Response.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException) { }
|
||||||
|
catch (HttpListenerException) { }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{Name}] Error: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnServerStopped(NetworkServer server)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
if (_httpListener != null && _httpListener.IsListening)
|
||||||
|
{
|
||||||
|
_httpListener.Stop();
|
||||||
|
_httpListener.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientConnected(Connection client) { }
|
||||||
|
public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) { }
|
||||||
|
public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) { }
|
||||||
|
}
|
||||||
|
}
|
53
EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs
Normal file
53
EonaCat.Connections/Plugins/Server/IdleTimeoutPlugin.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using EonaCat.Connections.Models;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.Plugins.Server
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
|
public class IdleTimeoutPlugin : IServerPlugin
|
||||||
|
{
|
||||||
|
public string Name => "IdleTimeoutPlugin";
|
||||||
|
|
||||||
|
private readonly TimeSpan _timeout;
|
||||||
|
private CancellationTokenSource _cts;
|
||||||
|
|
||||||
|
public IdleTimeoutPlugin(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
_timeout = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnServerStarted(NetworkServer server)
|
||||||
|
{
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Background task to check idle clients
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
foreach (var kvp in server.GetClients())
|
||||||
|
{
|
||||||
|
var client = kvp.Value;
|
||||||
|
if (DateTime.UtcNow - client.LastActive > _timeout)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{Name}] Disconnecting idle client {client.RemoteEndPoint}");
|
||||||
|
_ = server.DisconnectClientAsync(client.Id, DisconnectReason.Timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(5000, _cts.Token); // Check every 5s
|
||||||
|
}
|
||||||
|
}, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnServerStopped(NetworkServer server)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientConnected(Connection client) { }
|
||||||
|
public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) { }
|
||||||
|
public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) { }
|
||||||
|
}
|
||||||
|
}
|
65
EonaCat.Connections/Plugins/Server/MetricsPlugin.cs
Normal file
65
EonaCat.Connections/Plugins/Server/MetricsPlugin.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using EonaCat.Connections.Models;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.Plugins.Server
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
|
public class MetricsPlugin : IServerPlugin
|
||||||
|
{
|
||||||
|
public string Name => "MetricsPlugin";
|
||||||
|
|
||||||
|
private readonly TimeSpan _interval;
|
||||||
|
private CancellationTokenSource _cts;
|
||||||
|
private NetworkServer _server;
|
||||||
|
|
||||||
|
public MetricsPlugin(TimeSpan interval)
|
||||||
|
{
|
||||||
|
_interval = interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnServerStarted(NetworkServer server)
|
||||||
|
{
|
||||||
|
_server = server;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stats = server.GetStats();
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[{Name}] Uptime: {stats.Uptime:g} | " +
|
||||||
|
$"Active: {stats.ActiveConnections} | " +
|
||||||
|
$"Total: {stats.TotalConnections} | " +
|
||||||
|
$"Msgs In: {stats.MessagesReceived} | " +
|
||||||
|
$"Msgs Out: {stats.MessagesSent} | " +
|
||||||
|
$"Bytes In: {stats.BytesReceived} | " +
|
||||||
|
$"Bytes Out: {stats.BytesSent} | " +
|
||||||
|
$"Msg/s: {stats.MessagesPerSecond:F2}"
|
||||||
|
);
|
||||||
|
|
||||||
|
await Task.Delay(_interval, _cts.Token);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) { }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{Name}] Error logging metrics: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnServerStopped(NetworkServer server)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientConnected(Connection client) { }
|
||||||
|
public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception) { }
|
||||||
|
public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary) { }
|
||||||
|
}
|
||||||
|
}
|
57
EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs
Normal file
57
EonaCat.Connections/Plugins/Server/RateLimiterPlugin.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using EonaCat.Connections.Models;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.Plugins.Server
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
|
public class RateLimiterPlugin : IServerPlugin
|
||||||
|
{
|
||||||
|
public string Name => "RateLimiterPlugin";
|
||||||
|
|
||||||
|
private readonly int _maxMessages;
|
||||||
|
private readonly TimeSpan _interval;
|
||||||
|
private readonly ConcurrentDictionary<string, ConcurrentQueue<DateTime>> _messageTimestamps;
|
||||||
|
|
||||||
|
public RateLimiterPlugin(int maxMessages, TimeSpan interval)
|
||||||
|
{
|
||||||
|
_maxMessages = maxMessages;
|
||||||
|
_interval = interval;
|
||||||
|
_messageTimestamps = new ConcurrentDictionary<string, ConcurrentQueue<DateTime>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnServerStarted(NetworkServer server) { }
|
||||||
|
public void OnServerStopped(NetworkServer server) { }
|
||||||
|
|
||||||
|
public void OnClientConnected(Connection client)
|
||||||
|
{
|
||||||
|
_messageTimestamps[client.Id] = new ConcurrentQueue<DateTime>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClientDisconnected(Connection client, DisconnectReason reason, Exception exception)
|
||||||
|
{
|
||||||
|
_messageTimestamps.TryRemove(client.Id, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnDataReceived(Connection client, byte[] data, string stringData, bool isBinary)
|
||||||
|
{
|
||||||
|
if (!_messageTimestamps.TryGetValue(client.Id, out var queue)) return;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
queue.Enqueue(now);
|
||||||
|
|
||||||
|
// Remove old timestamps
|
||||||
|
while (queue.TryPeek(out var oldest) && now - oldest > _interval)
|
||||||
|
queue.TryDequeue(out _);
|
||||||
|
|
||||||
|
if (queue.Count > _maxMessages)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{Name}] Client {client.RemoteEndPoint} exceeded rate limit. Disconnecting...");
|
||||||
|
|
||||||
|
// Force disconnect
|
||||||
|
client.TcpClient?.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -7,6 +7,9 @@ using Timer = System.Timers.Timer;
|
|||||||
|
|
||||||
namespace EonaCat.Connections.Processors
|
namespace EonaCat.Connections.Processors
|
||||||
{
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Processes incoming data streams into JSON or text messages per client buffer.
|
/// Processes incoming data streams into JSON or text messages per client buffer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
Reference in New Issue
Block a user