Updated
This commit is contained in:
@@ -11,10 +11,4 @@
|
|||||||
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Update="client.pfx">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,82 +1,310 @@
|
|||||||
using EonaCat.Connections.Models;
|
using EonaCat.Connections.Models;
|
||||||
|
using EonaCat.Connections.Processors;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace EonaCat.Connections.Client.Example
|
namespace EonaCat.Connections.Client.Example
|
||||||
{
|
{
|
||||||
// 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 Program
|
public class Program
|
||||||
{
|
{
|
||||||
private static NetworkClient _client;
|
private const bool UseProcessor = true;
|
||||||
|
private const bool IsHeartBeatEnabled = false;
|
||||||
|
private static List<NetworkClient> _clients = new List<NetworkClient>();
|
||||||
|
private static long clientCount = 1;
|
||||||
|
private static long clientsConnected = 0;
|
||||||
|
private static string _jsonContent;
|
||||||
|
|
||||||
|
//public static string SERVER_IP = "10.40.11.22";
|
||||||
|
public static string SERVER_IP = "127.0.0.1";
|
||||||
|
|
||||||
|
private static Dictionary<NetworkClient, JsonDataProcessor<dynamic>> _clientsProcessors = new Dictionary<NetworkClient, JsonDataProcessor<dynamic>>();
|
||||||
|
private static bool testDataProcessor;
|
||||||
|
|
||||||
|
public static bool WaitForMessage { get; private set; }
|
||||||
|
public static bool PressEnterForNextMessage { get; private set; }
|
||||||
|
public static bool ToConsoleOnly { get; private set; } = true;
|
||||||
|
public static bool UseJson { get; private set; } = true;
|
||||||
|
public static bool TESTBYTES { get; private set; }
|
||||||
|
public static bool UseJsonProcessorTest { get; private set; } = false;
|
||||||
|
|
||||||
public static async Task Main(string[] args)
|
public static async Task Main(string[] args)
|
||||||
{
|
{
|
||||||
await CreateClientAsync().ConfigureAwait(false);
|
for (long i = 0; i < clientCount; i++)
|
||||||
|
{
|
||||||
|
var clientName = $"User {i}";
|
||||||
|
var client = await CreateClientAsync().ConfigureAwait(false);
|
||||||
|
_clients.Add(client);
|
||||||
|
|
||||||
|
if (testDataProcessor)
|
||||||
|
{
|
||||||
|
_clientsProcessors[client] = new JsonDataProcessor<dynamic>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ = StartClientAsync(clientName, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
if (!_client.IsConnected)
|
string message = string.Empty;
|
||||||
{
|
|
||||||
await Task.Delay(1000).ConfigureAwait(false);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.Write("Enter message to send (or 'exit' to quit): ");
|
if (WaitForMessage)
|
||||||
var message = Console.ReadLine();
|
{
|
||||||
|
Console.Write("Enter message to send (or 'exit' to quit): ");
|
||||||
|
message = Console.ReadLine();
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(message) && message.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(message) && message.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await _client.DisconnectAsync().ConfigureAwait(false);
|
foreach (var client in _clients)
|
||||||
|
{
|
||||||
|
await client.DisconnectClientAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var jsonUrl = "https://samples.json-format.com/employees/json/employees_500KB.json";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_jsonContent) && UseJson)
|
||||||
|
{
|
||||||
|
using var httpClient = new HttpClient();
|
||||||
|
_jsonContent = await httpClient.GetStringAsync(jsonUrl);
|
||||||
|
|
||||||
|
var jsonSize = Encoding.UTF8.GetByteCount(_jsonContent);
|
||||||
|
WriteToLog($"Using large JSON file (size: {jsonSize / 1024 / 1024} MB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UseJson)
|
||||||
|
{
|
||||||
|
message = _jsonContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UseJsonProcessorTest)
|
||||||
|
{
|
||||||
|
foreach (var client in _clients)
|
||||||
|
{
|
||||||
|
var processor = _clientsProcessors[client];
|
||||||
|
processor.OnProcessTextMessage += (sender, e) =>
|
||||||
|
{
|
||||||
|
WriteToLog($"Processed message from {e.ClientName}: {e.Text}");
|
||||||
|
};
|
||||||
|
processor.OnProcessMessage += (sender, e) =>
|
||||||
|
{
|
||||||
|
WriteToLog($"Processed JSON message from {e.ClientName} ({e.ClientEndpoint}): {e.RawData}");
|
||||||
|
};
|
||||||
|
processor.MaxAllowedBufferSize = 10 * 1024 * 1024; // 10 MB
|
||||||
|
processor.MaxMessagesPerBatch = 5;
|
||||||
|
var json = _jsonContent;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
processor.Process(json, "TestClient");
|
||||||
|
await Task.Delay(100).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
WriteToLog($"Failed to download large JSON file: {exception.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(message))
|
if (!string.IsNullOrEmpty(message))
|
||||||
{
|
{
|
||||||
await _client.SendAsync(message).ConfigureAwait(false);
|
foreach (var client in _clients)
|
||||||
|
{
|
||||||
|
if (TESTBYTES)
|
||||||
|
{
|
||||||
|
var bytes = new byte[] { 0x00, 0x04, 0x31, 0x32, 0x30, 0x30 };
|
||||||
|
await client.SendAsync(bytes).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await client.SendAsync(message).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Task.Delay(1000).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task CreateClientAsync()
|
private static async Task StartClientAsync(string clientName, NetworkClient client)
|
||||||
|
{
|
||||||
|
await client.ConnectAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Send nickname
|
||||||
|
await client.SendNicknameAsync(clientName);
|
||||||
|
|
||||||
|
// Send a message
|
||||||
|
await client.SendAsync($"Hello server, my name is {clientName}!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<NetworkClient> CreateClientAsync()
|
||||||
{
|
{
|
||||||
var config = new Configuration
|
var config = new Configuration
|
||||||
{
|
{
|
||||||
Protocol = ProtocolType.TCP,
|
Protocol = ProtocolType.TCP,
|
||||||
Host = "127.0.0.1",
|
Host = SERVER_IP,
|
||||||
Port = 1111,
|
Port = 1111,
|
||||||
UseSsl = true,
|
UseSsl = false,
|
||||||
UseAesEncryption = true,
|
UseAesEncryption = false,
|
||||||
|
EnableHeartbeat = IsHeartBeatEnabled,
|
||||||
AesPassword = "EonaCat.Connections.Password",
|
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);
|
var client = new NetworkClient(config);
|
||||||
|
|
||||||
_client.OnGeneralError += (sender, e) =>
|
|
||||||
Console.WriteLine($"Error: {e.Message}");
|
|
||||||
|
|
||||||
// Subscribe to events
|
// Subscribe to events
|
||||||
_client.OnConnected += async (sender, e) =>
|
client.OnConnected += (sender, e) =>
|
||||||
{
|
{
|
||||||
|
WriteToLog($"Connected to server at {e.RemoteEndPoint}");
|
||||||
Console.WriteLine($"Connected to server at {e.RemoteEndPoint}");
|
Console.WriteLine($"Connected to server at {e.RemoteEndPoint}");
|
||||||
|
Console.Title = $"Total clients {++clientsConnected}";
|
||||||
// Set nickname
|
|
||||||
await _client.SendNicknameAsync("TestUser");
|
|
||||||
|
|
||||||
// Send a message
|
|
||||||
await _client.SendAsync("Hello server!");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_client.OnDataReceived += (sender, e) =>
|
if (UseProcessor)
|
||||||
Console.WriteLine($"Server says: {(e.IsBinary ? $"{e.Data.Length} bytes" : e.StringData)}");
|
|
||||||
|
|
||||||
_client.OnDisconnected += (sender, e) =>
|
|
||||||
{
|
{
|
||||||
Console.WriteLine("Disconnected from server");
|
_clientsProcessors[client] = new JsonDataProcessor<object>();
|
||||||
|
_clientsProcessors[client].OnError += (sender, e) =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Processor error: {e.Message}");
|
||||||
|
};
|
||||||
|
|
||||||
|
_clientsProcessors[client].OnMessageError += (sender, e) =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Processor message error: {e.Message}");
|
||||||
|
};
|
||||||
|
|
||||||
|
_clientsProcessors[client].OnProcessTextMessage += (sender, e) =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Processed text message from {e.ClientName}: {e.Text}");
|
||||||
|
};
|
||||||
|
|
||||||
|
_clientsProcessors[client].OnProcessMessage += (sender, e) =>
|
||||||
|
{
|
||||||
|
ProcessMessage(e.RawData, e.ClientName, e.ClientEndpoint ?? "Unknown endpoint");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
client.OnDataReceived += (sender, e) =>
|
||||||
|
{
|
||||||
|
if (UseProcessor)
|
||||||
|
{
|
||||||
|
_clientsProcessors[client].Process(e, currentClientName: e.Nickname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WriteToLog($"Server says: {(e.IsBinary ? $"{e.Data.Length} bytes" : "We got a json message")}");
|
||||||
|
Console.WriteLine($"{e.StringData}");
|
||||||
|
|
||||||
|
if (PressEnterForNextMessage)
|
||||||
|
{
|
||||||
|
Console.ReadKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Console.WriteLine("Connecting to server...");
|
client.OnDisconnected += (sender, e) =>
|
||||||
await _client.ConnectAsync();
|
{
|
||||||
|
var message = string.Empty;
|
||||||
|
if (e.Reason == DisconnectReason.LocalClosed)
|
||||||
|
{
|
||||||
|
if (e.Exception != null)
|
||||||
|
{
|
||||||
|
message = $"Disconnected from server (local close). Exception: {e.Exception.Message}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
message = "Disconnected from server (local close).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (e.Exception != null)
|
||||||
|
{
|
||||||
|
message = $"Disconnected from server (remote close). Reason: {e.Reason}. Exception: {e.Exception.Message}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
message = $"Disconnected from server (remote close). Reason: {e.Reason}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteToLog(message);
|
||||||
|
Console.WriteLine(message);
|
||||||
|
Console.Title = $"Total clients {--clientsConnected}";
|
||||||
|
};
|
||||||
|
|
||||||
|
client.OnEncryptionError += Client_OnEncryptionError;
|
||||||
|
client.OnGeneralError += Client_OnGeneralError;
|
||||||
|
client.OnSslError += Client_OnSslError;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ProcessMessage(object message, string clientName, string remoteEndpoint)
|
||||||
|
{
|
||||||
|
WriteToLog($"Processed message from {clientName} ({remoteEndpoint}): {message}");
|
||||||
|
if (PressEnterForNextMessage)
|
||||||
|
{
|
||||||
|
Console.ReadKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Client_OnSslError(object? sender, EventArguments.ErrorEventArgs e)
|
||||||
|
{
|
||||||
|
WriteToLog($"SSL error: {e.Message} => {e.Exception?.Message} => {e.Nickname}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Client_OnGeneralError(object? sender, EventArguments.ErrorEventArgs e)
|
||||||
|
{
|
||||||
|
WriteToLog($"General error: {e.Message} => {e.Exception?.Message} => {e.Nickname}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Client_OnEncryptionError(object? sender, EventArguments.ErrorEventArgs e)
|
||||||
|
{
|
||||||
|
WriteToLog($"Encryption error: {e.Message} => {e.Exception?.Message} => {e.Nickname}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void WriteToLog(string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ToConsoleOnly)
|
||||||
|
{
|
||||||
|
var dateTimeNow = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
|
Console.WriteLine($"{dateTimeNow}: {message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var logFilePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? ".", "client_log.txt");
|
||||||
|
var logMessage = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {message}{Environment.NewLine}";
|
||||||
|
|
||||||
|
if (!File.Exists(logFilePath))
|
||||||
|
{
|
||||||
|
File.WriteAllText(logFilePath, logMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new FileInfo(logFilePath).Length > 5 * 1024 * 1024) // 5 MB
|
||||||
|
{
|
||||||
|
var archiveFilePath = Path.Combine(Path.GetDirectoryName(logFilePath) ?? ".", $"client_log_{DateTime.Now:yyyyMMdd_HHmmss}.txt");
|
||||||
|
File.Move(logFilePath, archiveFilePath);
|
||||||
|
File.WriteAllText(logFilePath, logMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.AppendAllText(logFilePath, logMessage);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore logging errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,10 +11,4 @@
|
|||||||
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Update="server.pfx">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
using EonaCat.Connections.Models;
|
using EonaCat.Connections.Models;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace EonaCat.Connections.Server.Example
|
namespace EonaCat.Connections.Server.Example
|
||||||
{
|
{
|
||||||
// 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 Program
|
public class Program
|
||||||
{
|
{
|
||||||
|
private const bool IsHeartBeatEnabled = false;
|
||||||
private static NetworkServer _server;
|
private static NetworkServer _server;
|
||||||
|
|
||||||
public static void Main(string[] args)
|
public static bool WaitForMessage { get; private set; } = true;
|
||||||
|
public static bool ToConsoleOnly { get; private set; } = true;
|
||||||
|
|
||||||
|
public static async Task Main(string[] args)
|
||||||
{
|
{
|
||||||
CreateServerAsync().ConfigureAwait(false);
|
CreateServerAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
Console.Write("Enter message to send (or 'exit' to quit): ");
|
var message = string.Empty;
|
||||||
var message = Console.ReadLine();
|
|
||||||
|
if (WaitForMessage)
|
||||||
|
{
|
||||||
|
Console.Write("Enter message to send (or 'exit' to quit): ");
|
||||||
|
message = Console.ReadLine();
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(message) && message.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(message) && message.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_server.Stop();
|
_server.Stop();
|
||||||
@@ -27,8 +35,10 @@ namespace EonaCat.Connections.Server.Example
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(message))
|
if (!string.IsNullOrEmpty(message))
|
||||||
{
|
{
|
||||||
_server.BroadcastAsync(message).ConfigureAwait(false);
|
await _server.BroadcastAsync(message).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Task.Delay(5000).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,33 +47,30 @@ namespace EonaCat.Connections.Server.Example
|
|||||||
var config = new Configuration
|
var config = new Configuration
|
||||||
{
|
{
|
||||||
Protocol = ProtocolType.TCP,
|
Protocol = ProtocolType.TCP,
|
||||||
|
Host = "0.0.0.0",
|
||||||
Port = 1111,
|
Port = 1111,
|
||||||
UseSsl = true,
|
UseSsl = false,
|
||||||
UseAesEncryption = true,
|
UseAesEncryption = false,
|
||||||
MaxConnections = 100000,
|
MaxConnections = 100000,
|
||||||
AesPassword = "EonaCat.Connections.Password",
|
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"),
|
||||||
|
EnableHeartbeat = IsHeartBeatEnabled
|
||||||
};
|
};
|
||||||
|
|
||||||
_server = new NetworkServer(config);
|
_server = new NetworkServer(config);
|
||||||
|
|
||||||
// Subscribe to events
|
// Subscribe to events
|
||||||
_server.OnConnected += (sender, e) =>
|
_server.OnConnected += (sender, e) =>
|
||||||
Console.WriteLine($"Client {e.ClientId} connected from {e.RemoteEndPoint}");
|
{
|
||||||
|
WriteToLog($"Client {e.ClientId} connected from {e.RemoteEndPoint}");
|
||||||
|
Console.WriteLine($"New connection from {e.RemoteEndPoint} with Client ID: {e.ClientId}");
|
||||||
|
};
|
||||||
|
|
||||||
_server.OnConnectedWithNickname += (sender, e) =>
|
_server.OnConnectedWithNickname += (sender, e) => WriteToLog($"Client {e.ClientId} connected with nickname: {e.Nickname}");
|
||||||
Console.WriteLine($"Client {e.ClientId} connected with nickname: {e.Nickname}");
|
|
||||||
|
|
||||||
_server.OnDataReceived += async (sender, e) =>
|
_server.OnDataReceived += async (sender, e) =>
|
||||||
{
|
{
|
||||||
if (e.HasNickname)
|
WriteToLog($"Received from {e.ClientId} ({e.RemoteEndPoint.ToString()}): {(e.IsBinary ? $"{e.Data.Length} bytes" : "a message")}");
|
||||||
{
|
|
||||||
Console.WriteLine($"Received from {e.Nickname}: {(e.IsBinary ? $"{e.Data.Length} bytes" : e.StringData)}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Received from {e.ClientId}: {(e.IsBinary ? $"{e.Data.Length} bytes" : e.StringData)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Echo back the message
|
// Echo back the message
|
||||||
if (e.IsBinary)
|
if (e.IsBinary)
|
||||||
@@ -72,23 +79,76 @@ namespace EonaCat.Connections.Server.Example
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await _server.SendToClientAsync(e.ClientId, $"Echo: {e.StringData}");
|
await _server.SendToClientAsync(e.ClientId, e.StringData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_server.OnDisconnected += (sender, e) =>
|
_server.OnDisconnected += (sender, e) =>
|
||||||
{
|
{
|
||||||
if (e.HasNickname)
|
var message = string.Empty;
|
||||||
|
if (e.Reason == DisconnectReason.LocalClosed)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Client {e.Nickname} disconnected");
|
if (e.Exception != null)
|
||||||
|
{
|
||||||
|
message = $"{e.Nickname} disconnected from server (local close). Exception: {e.Exception.Message}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
message = $"{e.Nickname} disconnected from server (local close).";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Client {e.ClientId} disconnected");
|
if (e.Exception != null)
|
||||||
|
{
|
||||||
|
message = $"{e.Nickname} disconnected from server (remote close). Reason: {e.Reason}. Exception: {e.Exception.Message}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
message = $"{e.Nickname} disconnected from server (remote close). Reason: {e.Reason}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
WriteToLog(message);
|
||||||
|
Console.WriteLine(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
await _server.StartAsync();
|
_ = _server.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void WriteToLog(string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ToConsoleOnly)
|
||||||
|
{
|
||||||
|
var dateTimeNow = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
|
Console.WriteLine($"{dateTimeNow}: {message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var logFilePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? ".", "server_log.txt");
|
||||||
|
var logMessage = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {message}{Environment.NewLine}";
|
||||||
|
|
||||||
|
if (!File.Exists(logFilePath))
|
||||||
|
{
|
||||||
|
File.WriteAllText(logFilePath, logMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new FileInfo(logFilePath).Length > 5 * 1024 * 1024) // 5 MB
|
||||||
|
{
|
||||||
|
var archiveFilePath = Path.Combine(Path.GetDirectoryName(logFilePath) ?? ".", $"server_log{DateTime.Now:yyyyMMdd_HHmmss}.txt");
|
||||||
|
File.Move(logFilePath, archiveFilePath);
|
||||||
|
File.WriteAllText(logFilePath, logMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.AppendAllText(logFilePath, logMessage);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore logging errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 18
|
||||||
VisualStudioVersion = 17.14.36408.4
|
VisualStudioVersion = 18.0.11205.157 d18.0
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.Connections", "EonaCat.Connections\EonaCat.Connections.csproj", "{D3925D5A-4791-C3BB-93E0-25AC0C0ED425}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.Connections", "EonaCat.Connections\EonaCat.Connections.csproj", "{D3925D5A-4791-C3BB-93E0-25AC0C0ED425}"
|
||||||
EndProject
|
EndProject
|
||||||
|
|||||||
16
EonaCat.Connections/BufferSize.cs
Normal file
16
EonaCat.Connections/BufferSize.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections
|
||||||
|
{
|
||||||
|
internal enum BufferSizeMaximum
|
||||||
|
{
|
||||||
|
Minimal = 8192,
|
||||||
|
Medium = 65536,
|
||||||
|
Large = 262144,
|
||||||
|
ExtraLarge = 1048576,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
using System;
|
namespace EonaCat.Connections
|
||||||
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
|
public enum DisconnectReason
|
||||||
{
|
{
|
||||||
Unknown,
|
Unknown,
|
||||||
@@ -15,9 +7,12 @@ namespace EonaCat.Connections
|
|||||||
LocalClosed,
|
LocalClosed,
|
||||||
Timeout,
|
Timeout,
|
||||||
Error,
|
Error,
|
||||||
|
SSLError,
|
||||||
ServerShutdown,
|
ServerShutdown,
|
||||||
Reconnect,
|
Reconnect,
|
||||||
ClientRequested,
|
ClientRequested,
|
||||||
Forced
|
Forced,
|
||||||
|
NoPongReceived,
|
||||||
|
ProtocolError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.8</Version>
|
<Version>1.0.9</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>
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.Connections</RepositoryUrl>
|
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.Connections</RepositoryUrl>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="EonaCat.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="..\EonaCat.png">
|
<None Include="..\EonaCat.png">
|
||||||
<Pack>True</Pack>
|
<Pack>True</Pack>
|
||||||
@@ -35,7 +39,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="EonaCat.Json" Version="1.1.9" />
|
<PackageReference Include="EonaCat.Json" Version="1.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Primitives" Version="10.0.0" />
|
||||||
|
<PackageReference Include="System.Buffers" Version="4.6.1" />
|
||||||
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
BIN
EonaCat.Connections/EonaCat.ico
Normal file
BIN
EonaCat.Connections/EonaCat.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
BIN
EonaCat.Connections/EonaCat.png
Normal file
BIN
EonaCat.Connections/EonaCat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -3,9 +3,6 @@ using System.Net.Sockets;
|
|||||||
|
|
||||||
namespace EonaCat.Connections.EventArguments
|
namespace EonaCat.Connections.EventArguments
|
||||||
{
|
{
|
||||||
// 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 ConnectionEventArgs : EventArgs
|
public class ConnectionEventArgs : EventArgs
|
||||||
{
|
{
|
||||||
public string ClientId { get; set; }
|
public string ClientId { get; set; }
|
||||||
@@ -34,17 +31,16 @@ namespace EonaCat.Connections.EventArguments
|
|||||||
public bool HasRemoteEndPointIPv6 => RemoteEndPoint?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
public bool HasRemoteEndPointIPv6 => RemoteEndPoint?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
||||||
public bool IsRemoteEndPointLoopback => RemoteEndPoint != null && IPAddress.IsLoopback(RemoteEndPoint.Address);
|
public bool IsRemoteEndPointLoopback => RemoteEndPoint != null && IPAddress.IsLoopback(RemoteEndPoint.Address);
|
||||||
|
|
||||||
|
public static DisconnectReason Determine(DisconnectReason reason, Exception exception)
|
||||||
public static DisconnectReason Determine(DisconnectReason reason, Exception ex)
|
|
||||||
{
|
{
|
||||||
if (ex == null)
|
if (exception == null)
|
||||||
{
|
{
|
||||||
return reason;
|
return reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ex is SocketException socketEx)
|
if (exception is SocketException socketException)
|
||||||
{
|
{
|
||||||
switch (socketEx.SocketErrorCode)
|
switch (socketException.SocketErrorCode)
|
||||||
{
|
{
|
||||||
case SocketError.ConnectionReset:
|
case SocketError.ConnectionReset:
|
||||||
case SocketError.Shutdown:
|
case SocketError.Shutdown:
|
||||||
@@ -64,13 +60,13 @@ namespace EonaCat.Connections.EventArguments
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ex is ObjectDisposedException || ex is InvalidOperationException)
|
if (exception is ObjectDisposedException || exception is InvalidOperationException)
|
||||||
{
|
{
|
||||||
return DisconnectReason.LocalClosed;
|
return DisconnectReason.LocalClosed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ex.Message.Contains("An existing connection was forcibly closed by the remote host")
|
if (exception.Message.Contains("An existing connection was forcibly closed by the remote host")
|
||||||
|| ex.Message.Contains("The remote party has closed the transport stream"))
|
|| exception.Message.Contains("The remote party has closed the transport stream"))
|
||||||
{
|
{
|
||||||
return DisconnectReason.RemoteClosed;
|
return DisconnectReason.RemoteClosed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
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 DataReceivedEventArgs : EventArgs
|
public class DataReceivedEventArgs : EventArgs
|
||||||
{
|
{
|
||||||
public string ClientId { get; internal set; }
|
public string ClientId { get; internal set; }
|
||||||
@@ -14,6 +11,6 @@ namespace EonaCat.Connections
|
|||||||
public DateTime Timestamp { get; internal set; } = DateTime.UtcNow;
|
public DateTime Timestamp { get; internal set; } = DateTime.UtcNow;
|
||||||
public IPEndPoint RemoteEndPoint { get; internal set; }
|
public IPEndPoint RemoteEndPoint { get; internal set; }
|
||||||
public string Nickname { get; internal set; }
|
public string Nickname { get; internal set; }
|
||||||
public bool HasNickname => !string.IsNullOrEmpty(Nickname);
|
public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
namespace EonaCat.Connections.EventArguments
|
namespace EonaCat.Connections.EventArguments
|
||||||
{
|
{
|
||||||
// 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 ErrorEventArgs : EventArgs
|
public class ErrorEventArgs : EventArgs
|
||||||
{
|
{
|
||||||
public string ClientId { get; set; }
|
public string ClientId { get; set; }
|
||||||
public string Nickname { get; set; }
|
public string Nickname { get; set; }
|
||||||
|
public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname);
|
||||||
public Exception Exception { get; set; }
|
public Exception Exception { get; set; }
|
||||||
public string Message { get; set; }
|
public string Message { get; set; }
|
||||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
|||||||
12
EonaCat.Connections/EventArguments/PingEventArgs.cs
Normal file
12
EonaCat.Connections/EventArguments/PingEventArgs.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.EventArguments
|
||||||
|
{
|
||||||
|
public class PingEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public DateTime ReceivedTime { get; set; }
|
||||||
|
public string Nickname { get; set; }
|
||||||
|
public IPEndPoint RemoteEndPoint { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,6 @@ using System.Text;
|
|||||||
|
|
||||||
namespace EonaCat.Connections.Helpers
|
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 AesKeyExchange
|
public static class AesKeyExchange
|
||||||
{
|
{
|
||||||
// 256-bit salt
|
// 256-bit salt
|
||||||
@@ -25,27 +22,26 @@ namespace EonaCat.Connections.Helpers
|
|||||||
|
|
||||||
private static readonly byte[] KeyConfirmationLabel = Encoding.UTF8.GetBytes("KEYCONFIRMATION");
|
private static readonly byte[] KeyConfirmationLabel = Encoding.UTF8.GetBytes("KEYCONFIRMATION");
|
||||||
|
|
||||||
public static async Task<byte[]> EncryptDataAsync(byte[] data, Aes aes)
|
public static async Task<int> EncryptDataAsync(byte[] buffer, int bytesToSend, Aes aes)
|
||||||
{
|
{
|
||||||
using (var encryptor = aes.CreateEncryptor())
|
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);
|
byte[] encrypted = await Task.Run(() => encryptor.TransformFinalBlock(buffer, 0, bytesToSend)).ConfigureAwait(false);
|
||||||
cs.FlushFinalBlock();
|
|
||||||
return ms.ToArray();
|
Buffer.BlockCopy(encrypted, 0, buffer, 0, encrypted.Length);
|
||||||
|
return encrypted.Length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<byte[]> DecryptDataAsync(byte[] data, Aes aes)
|
public static async Task<int> DecryptDataAsync(byte[] buffer, int bytesToSend, Aes aes)
|
||||||
{
|
{
|
||||||
using (var decryptor = aes.CreateDecryptor())
|
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);
|
byte[] decrypted = await Task.Run(() => decryptor.TransformFinalBlock(buffer, 0, bytesToSend)).ConfigureAwait(false);
|
||||||
return result.ToArray();
|
|
||||||
|
Buffer.BlockCopy(decrypted, 0, buffer, 0, decrypted.Length);
|
||||||
|
|
||||||
|
return decrypted.Length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,12 +131,12 @@ namespace EonaCat.Connections.Helpers
|
|||||||
Buffer.BlockCopy(keyMaterial, _aesKeySize, hmacKey, 0, _hmacKeySize);
|
Buffer.BlockCopy(keyMaterial, _aesKeySize, hmacKey, 0, _hmacKeySize);
|
||||||
|
|
||||||
byte[] expected;
|
byte[] expected;
|
||||||
using (var h = new HMACSHA256(hmacKey))
|
using (var hmac = new HMACSHA256(hmacKey))
|
||||||
{
|
{
|
||||||
h.TransformBlock(KeyConfirmationLabel, 0, KeyConfirmationLabel.Length, null, 0);
|
hmac.TransformBlock(KeyConfirmationLabel, 0, KeyConfirmationLabel.Length, null, 0);
|
||||||
h.TransformBlock(salt, 0, salt.Length, null, 0);
|
hmac.TransformBlock(salt, 0, salt.Length, null, 0);
|
||||||
h.TransformFinalBlock(iv, 0, iv.Length);
|
hmac.TransformFinalBlock(iv, 0, iv.Length);
|
||||||
expected = h.Hash;
|
expected = hmac.Hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!FixedTimeEquals(expected, keyConfirm))
|
if (!FixedTimeEquals(expected, keyConfirm))
|
||||||
@@ -158,7 +154,6 @@ namespace EonaCat.Connections.Helpers
|
|||||||
return aes;
|
return aes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static async Task WriteWithLengthAsync(Stream stream, byte[] data)
|
private static async Task WriteWithLengthAsync(Stream stream, byte[] data)
|
||||||
{
|
{
|
||||||
var byteLength = BitConverter.GetBytes(data.Length);
|
var byteLength = BitConverter.GetBytes(data.Length);
|
||||||
@@ -214,28 +209,28 @@ namespace EonaCat.Connections.Helpers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] RandomBytes(int n)
|
private static byte[] RandomBytes(int total)
|
||||||
{
|
{
|
||||||
var b = new byte[n];
|
var bytes = new byte[total];
|
||||||
using (var random = RandomNumberGenerator.Create())
|
using (var random = RandomNumberGenerator.Create())
|
||||||
{
|
{
|
||||||
random.GetBytes(b);
|
random.GetBytes(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return b;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool FixedTimeEquals(byte[] a, byte[] b)
|
private static bool FixedTimeEquals(byte[] firstByteArray, byte[] secondByteArray)
|
||||||
{
|
{
|
||||||
if (a == null || b == null || a.Length != b.Length)
|
if (firstByteArray == null || secondByteArray == null || firstByteArray.Length != secondByteArray.Length)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int difference = 0;
|
int difference = 0;
|
||||||
for (int i = 0; i < a.Length; i++)
|
for (int i = 0; i < firstByteArray.Length; i++)
|
||||||
{
|
{
|
||||||
difference |= a[i] ^ b[i];
|
difference |= firstByteArray[i] ^ secondByteArray[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
return difference == 0;
|
return difference == 0;
|
||||||
|
|||||||
20
EonaCat.Connections/Helpers/StringHelper.cs
Normal file
20
EonaCat.Connections/Helpers/StringHelper.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace EonaCat.Connections.Helpers
|
||||||
|
{
|
||||||
|
internal static class StringHelper
|
||||||
|
{
|
||||||
|
public static string GetTextBetweenTags(this string value,
|
||||||
|
string startTag,
|
||||||
|
string endTag)
|
||||||
|
{
|
||||||
|
if (value.Contains(startTag) && value.Contains(endTag))
|
||||||
|
{
|
||||||
|
int index = value.IndexOf(startTag) + startTag.Length;
|
||||||
|
return value.Substring(index, value.IndexOf(endTag) - index);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
namespace EonaCat.Connections.Helpers
|
|
||||||
{
|
|
||||||
internal class StringHelper
|
|
||||||
{
|
|
||||||
// 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 string GetTextBetweenTags(string message, string startTag, string endTag)
|
|
||||||
{
|
|
||||||
int startIndex = message.IndexOf(startTag);
|
|
||||||
if (startIndex == -1)
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
int endIndex = message.IndexOf(endTag, startIndex + startTag.Length);
|
|
||||||
if (endIndex == -1)
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
int length = endIndex - startIndex - startTag.Length;
|
|
||||||
if (length < 0)
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
return message.Substring(startIndex + startTag.Length, length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
EonaCat.Connections/Helpers/TcpSeparators.cs
Normal file
36
EonaCat.Connections/Helpers/TcpSeparators.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.Helpers
|
||||||
|
{
|
||||||
|
public static class TcpSeparators
|
||||||
|
{
|
||||||
|
public static byte[] NewLine => Encoding.UTF8.GetBytes("\n");
|
||||||
|
public static byte[] CarriageReturnNewLine => Encoding.UTF8.GetBytes("\r\n");
|
||||||
|
public static byte[] CarriageReturn => Encoding.UTF8.GetBytes("\r");
|
||||||
|
public static byte[] Tab => Encoding.UTF8.GetBytes("\t");
|
||||||
|
public static byte[] Space => Encoding.UTF8.GetBytes(" ");
|
||||||
|
public static byte[] Colon => Encoding.UTF8.GetBytes(":");
|
||||||
|
public static byte[] Comma => Encoding.UTF8.GetBytes(",");
|
||||||
|
public static byte[] SemiColon => Encoding.UTF8.GetBytes(";");
|
||||||
|
public static byte[] Equal => Encoding.UTF8.GetBytes("=");
|
||||||
|
public static byte[] Ampersand => Encoding.UTF8.GetBytes("&");
|
||||||
|
public static byte[] QuestionMark => Encoding.UTF8.GetBytes("?");
|
||||||
|
public static byte[] Slash => Encoding.UTF8.GetBytes("/");
|
||||||
|
public static byte[] BackSlash => Encoding.UTF8.GetBytes("\\");
|
||||||
|
public static byte[] Dot => Encoding.UTF8.GetBytes(".");
|
||||||
|
public static byte[] Dash => Encoding.UTF8.GetBytes("-");
|
||||||
|
public static byte[] Underscore => Encoding.UTF8.GetBytes("_");
|
||||||
|
public static byte[] Pipe => Encoding.UTF8.GetBytes("|");
|
||||||
|
public static byte[] ExclamationMark => Encoding.UTF8.GetBytes("!");
|
||||||
|
public static byte[] At => Encoding.UTF8.GetBytes("@");
|
||||||
|
public static byte[] Hash => Encoding.UTF8.GetBytes("#");
|
||||||
|
public static byte[] Dollar => Encoding.UTF8.GetBytes("$");
|
||||||
|
public static byte[] Percent => Encoding.UTF8.GetBytes("%");
|
||||||
|
public static byte[] Caret => Encoding.UTF8.GetBytes("^");
|
||||||
|
public static byte[] Asterisk => Encoding.UTF8.GetBytes("*");
|
||||||
|
public static byte[] OpenParenthesis => Encoding.UTF8.GetBytes("(");
|
||||||
|
public static byte[] CloseParenthesis => Encoding.UTF8.GetBytes(")");
|
||||||
|
public static byte[] OpenBracket => Encoding.UTF8.GetBytes("[");
|
||||||
|
public static byte[] CloseBracket => Encoding.UTF8.GetBytes("]");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,23 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
|
|
||||||
namespace EonaCat.Connections.Models
|
namespace EonaCat.Connections.Models
|
||||||
{
|
{
|
||||||
// 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 Configuration
|
public class Configuration
|
||||||
{
|
{
|
||||||
|
public event EventHandler<string> OnLog;
|
||||||
|
public List<string> TrustedThumbprints = new List<string> { "31446234774e63fed4bc252cf466ac1bed050439", "4e34475f8618f4cbd65a9852b8bf45e740a52fff", "b0d0de15cfe045547aeb403d0e9df8095a88a32a" };
|
||||||
public bool EnableAutoReconnect { get; set; } = true;
|
public bool EnableAutoReconnect { get; set; } = true;
|
||||||
public int ReconnectDelayMs { get; set; } = 5000;
|
public int ReconnectDelayInSeconds { get; set; } = 5;
|
||||||
public int MaxReconnectAttempts { get; set; } = 0; // 0 means unlimited attempts
|
public int MaxReconnectAttempts { get; set; } = 0; // 0 means unlimited attempts
|
||||||
|
public int SSLMaxRetries { get; set; } = 0; // 0 means unlimited attempts
|
||||||
|
public int SSLTimeoutInSeconds { get; set; } = 10;
|
||||||
|
public int SSLRetryDelayInSeconds { get; set; } = 2;
|
||||||
|
|
||||||
|
public FramingMode MessageFraming { get; set; } = FramingMode.None;
|
||||||
|
public byte[] Delimiter { get; internal set; } = Helpers.TcpSeparators.Percent;
|
||||||
|
public bool HasDelimiter => MessageFraming == FramingMode.Delimiter && Delimiter != null && Delimiter.Length > 0;
|
||||||
|
|
||||||
|
public const string PING_VALUE = "¯";
|
||||||
|
public const string PONG_VALUE = "‰";
|
||||||
|
|
||||||
public ProtocolType Protocol { get; set; } = ProtocolType.TCP;
|
public ProtocolType Protocol { get; set; } = ProtocolType.TCP;
|
||||||
public int Port { get; set; } = 8080;
|
public int Port { get; set; } = 8080;
|
||||||
@@ -19,63 +28,120 @@ namespace EonaCat.Connections.Models
|
|||||||
public bool UseSsl { get; set; } = false;
|
public bool UseSsl { get; set; } = false;
|
||||||
public X509Certificate2 Certificate { get; set; }
|
public X509Certificate2 Certificate { get; set; }
|
||||||
public bool UseAesEncryption { get; set; } = false;
|
public bool UseAesEncryption { get; set; } = false;
|
||||||
public int BufferSize { get; set; } = 8192;
|
public int BufferSize { get; set; } = (int)BufferSizeMaximum.Medium;
|
||||||
public int MaxConnections { get; set; } = 100000;
|
public int MaxConnections { get; set; } = 100000;
|
||||||
public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
public bool EnableKeepAlive { get; set; } = true;
|
public bool EnableKeepAlive { get; set; } = true;
|
||||||
public bool EnableNagle { get; set; } = false;
|
public bool EnableNagle { get; set; } = false;
|
||||||
|
|
||||||
// For testing purposes, allow self-signed certificates
|
// For testing purposes, allow self-signed certificates
|
||||||
public bool IsSelfSignedEnabled { get; set; } = true;
|
public bool IsSelfSignedEnabled { get; set; }
|
||||||
|
|
||||||
public string AesPassword { get; set; }
|
public string AesPassword { get; set; }
|
||||||
|
public bool CheckAgainstInternalTrustedCertificates { get; private set; } = true;
|
||||||
public bool CheckCertificateRevocation { get; set; }
|
public bool CheckCertificateRevocation { get; set; }
|
||||||
public bool MutuallyAuthenticate { get; set; } = true;
|
public bool MutuallyAuthenticate { get; set; } = true;
|
||||||
|
public double ClientTimeoutInMinutes { get; set; } = 10;
|
||||||
|
public bool EnableHeartbeat { get; set; }
|
||||||
|
public bool UseBigEndian { get; set; }
|
||||||
|
internal int HeartbeatIntervalSeconds { get; set; } = 5;
|
||||||
|
public bool EnablePingPongLogs { get; set; }
|
||||||
|
public int MAX_MESSAGE_SIZE { get; set; } = 100 * 1024 * 1024; // 100 MB
|
||||||
|
public bool DisconectOnMissedPong { get; set; }
|
||||||
|
|
||||||
internal RemoteCertificateValidationCallback GetRemoteCertificateValidationCallback()
|
internal RemoteCertificateValidationCallback GetRemoteCertificateValidationCallback()
|
||||||
{
|
{
|
||||||
return CertificateValidation;
|
return CertificateValidation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void DisableInternalCertificateCheck()
|
||||||
|
{
|
||||||
|
CheckAgainstInternalTrustedCertificates = false;
|
||||||
|
OnLog?.Invoke(this, "Internal certificate check disabled.");
|
||||||
|
}
|
||||||
|
|
||||||
private bool CertificateValidation(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
|
private bool CertificateValidation(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (IsSelfSignedEnabled)
|
if (IsSelfSignedEnabled)
|
||||||
{
|
{
|
||||||
|
OnLog?.Invoke(this, $"WARNING: Accepting all invalid certificates: {certificate?.Subject}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chain != null)
|
||||||
|
{
|
||||||
|
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||||
|
chain.ChainPolicy.VerificationFlags =
|
||||||
|
X509VerificationFlags.IgnoreCertificateAuthorityRevocationUnknown |
|
||||||
|
X509VerificationFlags.IgnoreEndRevocationUnknown |
|
||||||
|
X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||||
|
|
||||||
|
chain.Build((X509Certificate2)certificate);
|
||||||
|
|
||||||
|
foreach (var status in chain.ChainStatus)
|
||||||
|
{
|
||||||
|
OnLog?.Invoke(this, $"ChainStatus: {status.Status} - {status.StatusInformation}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sslPolicyErrors == SslPolicyErrors.None)
|
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||||
{
|
{
|
||||||
|
OnLog?.Invoke(this, $"Certificate validation succeeded in {stopwatch.ElapsedMilliseconds} ms");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (CheckAgainstInternalTrustedCertificates && certificate is X509Certificate2 cert2)
|
||||||
|
{
|
||||||
|
string thumbprint = cert2.Thumbprint?.Replace(" ", "").ToLowerInvariant();
|
||||||
|
if (thumbprint != null && TrustedThumbprints.Contains(thumbprint))
|
||||||
|
{
|
||||||
|
OnLog?.Invoke(this, $"Trusted thumbprint matched: {thumbprint}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
OnLog?.Invoke(this, $"Certificate thumbprint {thumbprint} not trusted (Validation took {stopwatch.ElapsedMilliseconds} ms)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors) && chain != null)
|
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors) && chain != null)
|
||||||
{
|
{
|
||||||
|
bool fatal = false;
|
||||||
foreach (var status in chain.ChainStatus)
|
foreach (var status in chain.ChainStatus)
|
||||||
{
|
{
|
||||||
if (status.Status == X509ChainStatusFlags.RevocationStatusUnknown ||
|
|
||||||
status.Status == X509ChainStatusFlags.OfflineRevocation)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.Status == X509ChainStatusFlags.Revoked)
|
if (status.Status == X509ChainStatusFlags.Revoked)
|
||||||
{
|
{
|
||||||
return false;
|
OnLog?.Invoke(this, $"Certificate revoked: {status.StatusInformation}");
|
||||||
|
fatal = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (status.Status == X509ChainStatusFlags.NotSignatureValid)
|
||||||
|
{
|
||||||
|
OnLog?.Invoke(this, $"Invalid signature: {status.StatusInformation}");
|
||||||
|
fatal = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
if (!fatal)
|
||||||
|
{
|
||||||
|
OnLog?.Invoke(this, $"Certificate accepted (ignoring minor chain warnings)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnLog?.Invoke(this, $"Certificate validation failed (Validation took {stopwatch.ElapsedMilliseconds} ms)");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OnLog?.Invoke(this, $"Certificate rejected: {sslPolicyErrors} (Validation took {stopwatch.ElapsedMilliseconds} ms)");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
sw.Stop();
|
stopwatch.Stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ using System.Security.Cryptography;
|
|||||||
|
|
||||||
namespace EonaCat.Connections.Models
|
namespace EonaCat.Connections.Models
|
||||||
{
|
{
|
||||||
// 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 Connection
|
public class Connection
|
||||||
{
|
{
|
||||||
public string Id { get; set; }
|
public string Id { get; set; }
|
||||||
@@ -16,6 +13,7 @@ namespace EonaCat.Connections.Models
|
|||||||
public Stream Stream { get; set; }
|
public Stream Stream { get; set; }
|
||||||
|
|
||||||
private string _nickName;
|
private string _nickName;
|
||||||
|
|
||||||
public string Nickname
|
public string Nickname
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -42,8 +40,160 @@ namespace EonaCat.Connections.Models
|
|||||||
|
|
||||||
public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id;
|
public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id;
|
||||||
|
|
||||||
public DateTime ConnectedAt { get; set; }
|
public bool IsConnected
|
||||||
public DateTime LastActive { get; set; }
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (TcpClient != null && TcpClient.Client != null)
|
||||||
|
{
|
||||||
|
return !(TcpClient.Client.Poll(1, SelectMode.SelectRead) && TcpClient.Client.Available == 0);
|
||||||
|
}
|
||||||
|
else if (UdpClient != null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime ConnectedAt { get; internal set; }
|
||||||
|
public DateTime LastActive { get; internal set; }
|
||||||
|
public DateTime DisconnectionTime { get; internal set; }
|
||||||
|
public DateTime LastDataSent { get; internal set; }
|
||||||
|
public DateTime LastDataReceived { get; internal set; }
|
||||||
|
|
||||||
|
public int IdleTimeInSeconds()
|
||||||
|
{
|
||||||
|
var idleTime = IdleTime();
|
||||||
|
return (int)idleTime.TotalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int IdleTimeInMinutes()
|
||||||
|
{
|
||||||
|
var idleTime = IdleTime();
|
||||||
|
return (int)idleTime.TotalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int IdleTimeInHours()
|
||||||
|
{
|
||||||
|
var idleTime = IdleTime();
|
||||||
|
return (int)idleTime.TotalHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int IdleTimeInDays()
|
||||||
|
{
|
||||||
|
var idleTime = IdleTime();
|
||||||
|
return (int)idleTime.TotalDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan IdleTime()
|
||||||
|
{
|
||||||
|
return DateTime.UtcNow - LastActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string IdleTimeFormatted(bool includeDays = true, bool includeHours = true, bool includeMinutes = true, bool includeSeconds = true, bool includeMilliseconds = true)
|
||||||
|
{
|
||||||
|
var idleTime = IdleTime();
|
||||||
|
var parts = new List<string>();
|
||||||
|
|
||||||
|
if (includeDays)
|
||||||
|
{
|
||||||
|
parts.Add($"{(int)idleTime.TotalDays:D2}d");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeHours)
|
||||||
|
{
|
||||||
|
parts.Add($"{idleTime.Hours:D2}h");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeMinutes)
|
||||||
|
{
|
||||||
|
parts.Add($"{idleTime.Minutes:D2}m");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeSeconds)
|
||||||
|
{
|
||||||
|
parts.Add($"{idleTime.Seconds:D2}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeMilliseconds)
|
||||||
|
{
|
||||||
|
parts.Add($"{idleTime.Milliseconds:D3}ms");
|
||||||
|
}
|
||||||
|
return string.Join(" ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ConnectedTimeInSeconds()
|
||||||
|
{
|
||||||
|
var connectedTime = DateTime.UtcNow - ConnectedAt;
|
||||||
|
return (int)connectedTime.TotalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ConnectedTimeInMinutes()
|
||||||
|
{
|
||||||
|
var connectedTime = DateTime.UtcNow - ConnectedAt;
|
||||||
|
return (int)connectedTime.TotalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ConnectedTimeInHours()
|
||||||
|
{
|
||||||
|
var connectedTime = DateTime.UtcNow - ConnectedAt;
|
||||||
|
return (int)connectedTime.TotalHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ConnectedTimeInDays()
|
||||||
|
{
|
||||||
|
var connectedTime = DateTime.UtcNow - ConnectedAt;
|
||||||
|
return (int)connectedTime.TotalDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan ConnectedTime()
|
||||||
|
{
|
||||||
|
return DateTime.UtcNow - ConnectedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ConnectedTimeFormatted(bool includeDays = true, bool includeHours = true, bool includeMinutes = true, bool includeSeconds = true, bool includeMilliseconds = true)
|
||||||
|
{
|
||||||
|
var connectedTime = ConnectedTime();
|
||||||
|
var parts = new List<string>();
|
||||||
|
|
||||||
|
if (includeDays)
|
||||||
|
{
|
||||||
|
parts.Add($"{(int)connectedTime.TotalDays:D2}d");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeHours)
|
||||||
|
{
|
||||||
|
parts.Add($"{connectedTime.Hours:D2}h");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeMinutes)
|
||||||
|
{
|
||||||
|
parts.Add($"{connectedTime.Minutes:D2}m");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeSeconds)
|
||||||
|
{
|
||||||
|
parts.Add($"{connectedTime.Seconds:D2}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeMilliseconds)
|
||||||
|
{
|
||||||
|
parts.Add($"{connectedTime.Milliseconds:D3}ms");
|
||||||
|
}
|
||||||
|
return string.Join(" ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
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; }
|
||||||
@@ -54,12 +204,17 @@ namespace EonaCat.Connections.Models
|
|||||||
public long BytesSent => Interlocked.Read(ref _bytesSent);
|
public long BytesSent => Interlocked.Read(ref _bytesSent);
|
||||||
|
|
||||||
public void AddBytesReceived(long count) => Interlocked.Add(ref _bytesReceived, count);
|
public void AddBytesReceived(long count) => Interlocked.Add(ref _bytesReceived, count);
|
||||||
|
|
||||||
public void AddBytesSent(long count) => Interlocked.Add(ref _bytesSent, count);
|
public void AddBytesSent(long count) => Interlocked.Add(ref _bytesSent, count);
|
||||||
|
|
||||||
public SemaphoreSlim SendLock { get; } = new SemaphoreSlim(1, 1);
|
public SemaphoreSlim SendLock { get; } = new SemaphoreSlim(1, 1);
|
||||||
public SemaphoreSlim ReadLock { get; } = new SemaphoreSlim(1, 1);
|
public SemaphoreSlim ReadLock { get; } = new SemaphoreSlim(1, 1);
|
||||||
|
internal Task ReceiveTask { get; set; }
|
||||||
|
|
||||||
private int _disconnected;
|
private int _disconnected;
|
||||||
|
|
||||||
public bool MarkDisconnected() => Interlocked.Exchange(ref _disconnected, 1) == 0;
|
public bool MarkDisconnected() => Interlocked.Exchange(ref _disconnected, 1) == 0;
|
||||||
|
|
||||||
|
public Dictionary<string, object> Metadata { get; } = new Dictionary<string, object>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
EonaCat.Connections/Models/FramingMode.cs
Normal file
9
EonaCat.Connections/Models/FramingMode.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EonaCat.Connections.Models
|
||||||
|
{
|
||||||
|
public enum FramingMode
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Delimiter,
|
||||||
|
LengthPrefixed
|
||||||
|
}
|
||||||
|
}
|
||||||
13
EonaCat.Connections/Models/ProcessedMessage.cs
Normal file
13
EonaCat.Connections/Models/ProcessedMessage.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EonaCat.Connections.Models
|
||||||
|
{
|
||||||
|
public class ProcessedMessage<TData>
|
||||||
|
{
|
||||||
|
public TData Data { get; set; }
|
||||||
|
public string RawData { get; set; }
|
||||||
|
public string ClientName { get; set; }
|
||||||
|
public string? ClientEndpoint { get; set; }
|
||||||
|
public bool HasClientEndpoint => !string.IsNullOrEmpty(ClientEndpoint);
|
||||||
|
public bool HasRawData => !string.IsNullOrEmpty(RawData);
|
||||||
|
public bool HasObject => Data != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
EonaCat.Connections/Models/ProcessedTextMessage.cs
Normal file
9
EonaCat.Connections/Models/ProcessedTextMessage.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EonaCat.Connections.Models
|
||||||
|
{
|
||||||
|
public class ProcessedTextMessage
|
||||||
|
{
|
||||||
|
public string Text { get; set; }
|
||||||
|
public string ClientName { get; set; }
|
||||||
|
public string? ClientEndpoint { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
8
EonaCat.Connections/Models/ProtocolType.cs
Normal file
8
EonaCat.Connections/Models/ProtocolType.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace EonaCat.Connections.Models
|
||||||
|
{
|
||||||
|
public enum ProtocolType
|
||||||
|
{
|
||||||
|
TCP,
|
||||||
|
UDP
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
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 Stats
|
public class Stats
|
||||||
{
|
{
|
||||||
public int ActiveConnections { get; set; }
|
public int ActiveConnections { get; set; }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,112 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +1,243 @@
|
|||||||
using EonaCat.Json;
|
using EonaCat.Json;
|
||||||
using EonaCat.Json.Linq;
|
using EonaCat.Json.Linq;
|
||||||
|
using EonaCat.Connections.Models;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Timers;
|
using System.Timers;
|
||||||
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.
|
public sealed class JsonDataProcessor<TData> : IDisposable
|
||||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Processes incoming data streams into JSON or text messages per client buffer.
|
|
||||||
/// </summary>
|
|
||||||
public class JsonDataProcessor<TMessage> : IDisposable
|
|
||||||
{
|
{
|
||||||
private const int DefaultMaxBufferSize = 20 * 1024 * 1024; // 20 MB
|
public int MaxAllowedBufferSize = 20 * 1024 * 1024;
|
||||||
private const int DefaultMaxMessagesPerBatch = 200;
|
public int MaxMessagesPerBatch = 200;
|
||||||
private static readonly TimeSpan DefaultClientBufferTimeout = TimeSpan.FromMinutes(5);
|
private const int MaxCleanupRemovalsPerTick = 50;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, BufferEntry> _buffers = new ConcurrentDictionary<string, BufferEntry>();
|
private readonly ConcurrentDictionary<string, BufferEntry> _buffers = new();
|
||||||
private readonly Timer _cleanupTimer;
|
private readonly System.Timers.Timer _cleanupTimer;
|
||||||
|
private readonly TimeSpan _clientBufferTimeout = TimeSpan.FromMinutes(5);
|
||||||
private bool _isDisposed;
|
private bool _isDisposed;
|
||||||
|
|
||||||
/// <summary>
|
public string ClientName { get; }
|
||||||
/// Maximum allowed buffer size in bytes (default: 20 MB).
|
|
||||||
/// </summary>
|
|
||||||
public int MaxAllowedBufferSize { get; set; } = DefaultMaxBufferSize;
|
|
||||||
|
|
||||||
/// <summary>
|
private sealed class BufferEntry
|
||||||
/// Maximum number of messages processed per batch (default: 200).
|
|
||||||
/// </summary>
|
|
||||||
public int MaxMessagesPerBatch { get; set; } = DefaultMaxMessagesPerBatch;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Default client name when one is not provided in <see cref="DataReceivedEventArgs"/>.
|
|
||||||
/// </summary>
|
|
||||||
public string ClientName { get; set; } = Guid.NewGuid().ToString();
|
|
||||||
|
|
||||||
public Action<TMessage, string, string> ProcessMessage { get; set; }
|
|
||||||
public Action<string, string> ProcessTextMessage { get; set; }
|
|
||||||
|
|
||||||
public event EventHandler<Exception> OnMessageError;
|
|
||||||
public event EventHandler<Exception> OnError;
|
|
||||||
|
|
||||||
private class BufferEntry
|
|
||||||
{
|
{
|
||||||
public readonly StringBuilder Buffer = new StringBuilder();
|
public readonly StringBuilder Buffer = new();
|
||||||
public DateTime LastUsed = DateTime.UtcNow;
|
public DateTime LastUsed = DateTime.UtcNow;
|
||||||
public readonly object SyncRoot = new object();
|
public readonly object SyncRoot = new();
|
||||||
|
|
||||||
|
public void Clear(bool shrink = false)
|
||||||
|
{
|
||||||
|
Buffer.Clear();
|
||||||
|
if (shrink && Buffer.Capacity > 1024)
|
||||||
|
{
|
||||||
|
Buffer.Capacity = 1024;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public event EventHandler<ProcessedMessage<TData>>? OnProcessMessage;
|
||||||
|
|
||||||
|
public event EventHandler<ProcessedTextMessage>? OnProcessTextMessage;
|
||||||
|
|
||||||
|
public event EventHandler<Exception>? OnMessageError;
|
||||||
|
|
||||||
|
public event EventHandler<Exception>? OnError;
|
||||||
|
|
||||||
public JsonDataProcessor()
|
public JsonDataProcessor()
|
||||||
{
|
{
|
||||||
_cleanupTimer = new Timer(DefaultClientBufferTimeout.TotalMilliseconds / 5);
|
ClientName = Guid.NewGuid().ToString();
|
||||||
_cleanupTimer.AutoReset = true;
|
|
||||||
|
_cleanupTimer = new System.Timers.Timer(Math.Max(5000, _clientBufferTimeout.TotalMilliseconds / 5))
|
||||||
|
{
|
||||||
|
AutoReset = true
|
||||||
|
};
|
||||||
_cleanupTimer.Elapsed += CleanupInactiveClients;
|
_cleanupTimer.Elapsed += CleanupInactiveClients;
|
||||||
_cleanupTimer.Start();
|
_cleanupTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public void Process(DataReceivedEventArgs e, string? currentClientName = null)
|
||||||
/// Process incoming raw data.
|
|
||||||
/// </summary>
|
|
||||||
public void Process(DataReceivedEventArgs e)
|
|
||||||
{
|
{
|
||||||
EnsureNotDisposed();
|
ThrowIfDisposed();
|
||||||
|
if (e == null)
|
||||||
if (e.IsBinary)
|
|
||||||
{
|
|
||||||
e.StringData = Encoding.UTF8.GetString(e.Data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(e.StringData))
|
|
||||||
{
|
|
||||||
OnError?.Invoke(this, new Exception("Received empty data."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
string clientName = string.IsNullOrWhiteSpace(e.Nickname) ? ClientName : e.Nickname;
|
|
||||||
string incomingText = e.StringData.Trim();
|
|
||||||
if (incomingText.Length == 0)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string endpoint = e.RemoteEndPoint?.ToString();
|
||||||
|
string dataString = e.IsBinary ? Encoding.UTF8.GetString(e.Data ?? Array.Empty<byte>()) : e.StringData;
|
||||||
|
if (string.IsNullOrWhiteSpace(dataString))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string client = e.Nickname ?? currentClientName ?? ClientName;
|
||||||
|
ProcessInternal(dataString.Trim(), client, endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Process(string jsonString, string? currentClientName = null, string? endpoint = null)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (string.IsNullOrWhiteSpace(jsonString))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string client = currentClientName ?? ClientName;
|
||||||
|
ProcessInternal(jsonString.Trim(), client, endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessInternal(string jsonString, string clientName, string? clientEndpoint)
|
||||||
|
{
|
||||||
var bufferEntry = _buffers.GetOrAdd(clientName, _ => new BufferEntry());
|
var bufferEntry = _buffers.GetOrAdd(clientName, _ => new BufferEntry());
|
||||||
|
var pendingJson = new List<string>();
|
||||||
|
var pendingText = new List<string>();
|
||||||
|
|
||||||
lock (bufferEntry.SyncRoot)
|
lock (bufferEntry.SyncRoot)
|
||||||
{
|
{
|
||||||
|
// Check for buffer overflow
|
||||||
if (bufferEntry.Buffer.Length > MaxAllowedBufferSize)
|
if (bufferEntry.Buffer.Length > MaxAllowedBufferSize)
|
||||||
{
|
{
|
||||||
bufferEntry.Buffer.Clear();
|
OnError?.Invoke(this, new Exception($"Buffer overflow ({MaxAllowedBufferSize} bytes) for client {clientName} ({clientEndpoint})."));
|
||||||
|
bufferEntry.Clear(shrink: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
bufferEntry.Buffer.Append(incomingText);
|
bufferEntry.Buffer.Append(jsonString);
|
||||||
bufferEntry.LastUsed = DateTime.UtcNow;
|
bufferEntry.LastUsed = DateTime.UtcNow;
|
||||||
|
|
||||||
int processedCount = 0;
|
int processedCount = 0;
|
||||||
|
|
||||||
while (processedCount < MaxMessagesPerBatch &&
|
while (processedCount < MaxMessagesPerBatch &&
|
||||||
ExtractNextJson(bufferEntry.Buffer, out var jsonChunk))
|
JsonDataProcessorHelper.TryExtractCompleteJson(bufferEntry.Buffer, out string[] json, out string[] nonJsonText))
|
||||||
{
|
{
|
||||||
ProcessDataReceived(jsonChunk, clientName);
|
// No more messages
|
||||||
processedCount++;
|
if ((json == null || json.Length == 0) && (nonJsonText == null || nonJsonText.Length == 0))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json != null && json.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (var jsonMessage in json)
|
||||||
|
{
|
||||||
|
pendingJson.Add(jsonMessage);
|
||||||
|
processedCount++;
|
||||||
|
if (processedCount >= MaxMessagesPerBatch)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonJsonText != null && nonJsonText.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (var textMessage in nonJsonText)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(textMessage))
|
||||||
|
{
|
||||||
|
pendingText.Add(textMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup buffer if needed
|
||||||
|
if (bufferEntry.Buffer.Capacity > MaxAllowedBufferSize / 2)
|
||||||
|
{
|
||||||
|
bufferEntry.Clear(shrink: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle leftover non-JSON text
|
|
||||||
if (bufferEntry.Buffer.Length > 0 && !ContainsJsonStructure(bufferEntry.Buffer))
|
if (bufferEntry.Buffer.Length > 0 && !ContainsJsonStructure(bufferEntry.Buffer))
|
||||||
{
|
{
|
||||||
var leftover = bufferEntry.Buffer.ToString();
|
string leftover = bufferEntry.Buffer.ToString();
|
||||||
bufferEntry.Buffer.Clear();
|
bufferEntry.Clear(shrink: true);
|
||||||
ProcessTextMessage?.Invoke(leftover, clientName);
|
if (!string.IsNullOrWhiteSpace(leftover))
|
||||||
|
{
|
||||||
|
pendingText.Add(leftover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingText.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var textMessage in pendingText)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
OnProcessTextMessage?.Invoke(this, new ProcessedTextMessage { Text = textMessage, ClientName = clientName, ClientEndpoint = clientEndpoint });
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
OnError?.Invoke(this, new Exception($"ProcessTextMessage handler threw for client {clientName} ({clientEndpoint}).", exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingJson.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var jsonMessage in pendingJson)
|
||||||
|
{
|
||||||
|
ProcessDataReceived(jsonMessage, clientName, clientEndpoint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ProcessDataReceived(string data, string clientName)
|
private void ProcessDataReceived(string data, string clientName, string? clientEndpoint)
|
||||||
{
|
{
|
||||||
EnsureNotDisposed();
|
ThrowIfDisposed();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(data))
|
if (string.IsNullOrWhiteSpace(data))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(clientName))
|
|
||||||
{
|
|
||||||
clientName = ClientName;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool looksLikeJson = data.Length > 1 &&
|
bool looksLikeJson = data.Length > 1 &&
|
||||||
((data[0] == '{' && data[data.Length - 1] == '}') ||
|
((data[0] == '{' && data[data.Length - 1] == '}') ||
|
||||||
(data[0] == '[' && data[data.Length - 1] == ']') ||
|
(data[0] == '[' && data[data.Length - 1] == ']'));
|
||||||
data[0] == '"' || // string
|
|
||||||
char.IsDigit(data[0]) || data[0] == '-' || // numbers
|
|
||||||
data.StartsWith("true") ||
|
|
||||||
data.StartsWith("false") ||
|
|
||||||
data.StartsWith("null"));
|
|
||||||
|
|
||||||
if (!looksLikeJson)
|
if (!looksLikeJson)
|
||||||
{
|
{
|
||||||
ProcessTextMessage?.Invoke(data, clientName);
|
try
|
||||||
|
{
|
||||||
|
OnProcessTextMessage?.Invoke(this, new ProcessedTextMessage { Text = data, ClientName = clientName, ClientEndpoint = clientEndpoint });
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
OnError?.Invoke(this, new Exception($"ProcessTextMessage handler threw for client {clientName} ({clientEndpoint}).", exception));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Try to detect JSON-encoded exceptions
|
if (data.Contains("Exception", StringComparison.OrdinalIgnoreCase) ||
|
||||||
if (data.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
data.Contains("Error", StringComparison.OrdinalIgnoreCase))
|
||||||
data.IndexOf("Error", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
||||||
{
|
{
|
||||||
TryHandleJsonException(data);
|
GetError(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
var messages = JsonHelper.ToObjects<TMessage>(data);
|
var messages = JsonHelper.ToObjects<TData>(data);
|
||||||
if (messages != null && ProcessMessage != null)
|
if (messages != null)
|
||||||
{
|
{
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
ProcessMessage(message, clientName, data);
|
try
|
||||||
|
{
|
||||||
|
OnProcessMessage?.Invoke(this, new ProcessedMessage<TData> { Data = message, RawData = data, ClientName = clientName, ClientEndpoint = clientEndpoint });
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
OnError?.Invoke(this, new Exception($"ProcessMessage handler threw for client {clientName} ({clientEndpoint}).", exception));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
OnError?.Invoke(this, new Exception("Failed to process JSON message.", ex));
|
OnError?.Invoke(this, new Exception($"Failed to process JSON message from {clientName} ({clientEndpoint}).", exception));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryHandleJsonException(string data)
|
private void GetError(string data)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -176,142 +245,25 @@ namespace EonaCat.Connections.Processors
|
|||||||
var exceptionToken = jsonObject.SelectToken("Exception");
|
var exceptionToken = jsonObject.SelectToken("Exception");
|
||||||
if (exceptionToken != null && exceptionToken.Type != JTokenType.Null)
|
if (exceptionToken != null && exceptionToken.Type != JTokenType.Null)
|
||||||
{
|
{
|
||||||
var exception = JsonHelper.ExtractException(data);
|
var extracted = JsonHelper.ExtractException(data);
|
||||||
if (exception != null && OnMessageError != null)
|
if (extracted != null)
|
||||||
{
|
{
|
||||||
OnMessageError(this, new Exception(exception.Message));
|
OnMessageError?.Invoke(this, new Exception(extracted.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Ignore malformed exception JSON
|
// Do nothing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ExtractNextJson(StringBuilder buffer, out string json)
|
|
||||||
{
|
|
||||||
json = null;
|
|
||||||
if (buffer.Length == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int depth = 0;
|
|
||||||
bool inString = false, escape = false;
|
|
||||||
int startIndex = -1;
|
|
||||||
|
|
||||||
for (int i = 0; i < buffer.Length; i++)
|
|
||||||
{
|
|
||||||
char c = buffer[i];
|
|
||||||
|
|
||||||
if (inString)
|
|
||||||
{
|
|
||||||
if (escape)
|
|
||||||
{
|
|
||||||
escape = false;
|
|
||||||
}
|
|
||||||
else if (c == '\\')
|
|
||||||
{
|
|
||||||
escape = true;
|
|
||||||
}
|
|
||||||
else if (c == '"')
|
|
||||||
{
|
|
||||||
inString = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
switch (c)
|
|
||||||
{
|
|
||||||
case '"':
|
|
||||||
inString = true;
|
|
||||||
if (depth == 0 && startIndex == -1)
|
|
||||||
{
|
|
||||||
startIndex = i; // string-only JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case '{':
|
|
||||||
case '[':
|
|
||||||
if (depth == 0)
|
|
||||||
{
|
|
||||||
startIndex = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
depth++;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case '}':
|
|
||||||
case ']':
|
|
||||||
depth--;
|
|
||||||
if (depth == 0 && startIndex != -1)
|
|
||||||
{
|
|
||||||
int length = i - startIndex + 1;
|
|
||||||
json = buffer.ToString(startIndex, length);
|
|
||||||
buffer.Remove(0, i + 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
if (depth == 0 && startIndex == -1 &&
|
|
||||||
(char.IsDigit(c) || c == '-' || c == 't' || c == 'f' || c == 'n'))
|
|
||||||
{
|
|
||||||
startIndex = i;
|
|
||||||
int tokenEnd = FindPrimitiveEnd(buffer, i);
|
|
||||||
json = buffer.ToString(startIndex, tokenEnd - startIndex);
|
|
||||||
buffer.Remove(0, tokenEnd);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int FindPrimitiveEnd(StringBuilder buffer, int startIndex)
|
|
||||||
{
|
|
||||||
// Keywords: true/false/null
|
|
||||||
if (buffer.Length >= startIndex + 4 && buffer.ToString(startIndex, 4) == "true")
|
|
||||||
{
|
|
||||||
return startIndex + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buffer.Length >= startIndex + 5 && buffer.ToString(startIndex, 5) == "false")
|
|
||||||
{
|
|
||||||
return startIndex + 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buffer.Length >= startIndex + 4 && buffer.ToString(startIndex, 4) == "null")
|
|
||||||
{
|
|
||||||
return startIndex + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Numbers: scan until non-number/decimal/exponent
|
|
||||||
int i = startIndex;
|
|
||||||
while (i < buffer.Length)
|
|
||||||
{
|
|
||||||
char c = buffer[i];
|
|
||||||
if (!(char.IsDigit(c) || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E'))
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ContainsJsonStructure(StringBuilder buffer)
|
private static bool ContainsJsonStructure(StringBuilder buffer)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < buffer.Length; i++)
|
for (int i = 0; i < buffer.Length; i++)
|
||||||
{
|
{
|
||||||
char c = buffer[i];
|
char c = buffer[i];
|
||||||
if (c == '{' || c == '[' || c == '"' || c == 't' || c == 'f' || c == 'n' || c == '-' || char.IsDigit(c))
|
if (c == '{' || c == '[')
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -319,22 +271,36 @@ namespace EonaCat.Connections.Processors
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CleanupInactiveClients(object sender, ElapsedEventArgs e)
|
private void CleanupInactiveClients(object? sender, ElapsedEventArgs e)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
if (_isDisposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
var keysToRemove = new List<string>(capacity: 128);
|
||||||
|
|
||||||
foreach (var kvp in _buffers)
|
foreach (var kvp in _buffers)
|
||||||
{
|
{
|
||||||
var bufferEntry = kvp.Value;
|
if (now - kvp.Value.LastUsed > _clientBufferTimeout)
|
||||||
if (now - bufferEntry.LastUsed > DefaultClientBufferTimeout)
|
|
||||||
{
|
{
|
||||||
BufferEntry removed;
|
keysToRemove.Add(kvp.Key);
|
||||||
if (_buffers.TryRemove(kvp.Key, out removed))
|
if (keysToRemove.Count >= MaxCleanupRemovalsPerTick)
|
||||||
{
|
{
|
||||||
lock (removed.SyncRoot)
|
break;
|
||||||
{
|
}
|
||||||
removed.Buffer.Clear();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the selected keys
|
||||||
|
foreach (var key in keysToRemove)
|
||||||
|
{
|
||||||
|
if (_buffers.TryRemove(key, out var removed))
|
||||||
|
{
|
||||||
|
lock (removed.SyncRoot)
|
||||||
|
{
|
||||||
|
removed.Clear(shrink: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,21 +313,20 @@ namespace EonaCat.Connections.Processors
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
BufferEntry removed;
|
if (_buffers.TryRemove(clientName, out var removed))
|
||||||
if (_buffers.TryRemove(clientName, out removed))
|
|
||||||
{
|
{
|
||||||
lock (removed.SyncRoot)
|
lock (removed.SyncRoot)
|
||||||
{
|
{
|
||||||
removed.Buffer.Clear();
|
removed.Clear(shrink: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureNotDisposed()
|
private void ThrowIfDisposed()
|
||||||
{
|
{
|
||||||
if (_isDisposed)
|
if (_isDisposed)
|
||||||
{
|
{
|
||||||
throw new ObjectDisposedException(nameof(JsonDataProcessor<TMessage>));
|
throw new ObjectDisposedException(nameof(JsonDataProcessor<TData>));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,30 +337,33 @@ namespace EonaCat.Connections.Processors
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
_isDisposed = true;
|
||||||
{
|
|
||||||
_cleanupTimer.Stop();
|
|
||||||
_cleanupTimer.Elapsed -= CleanupInactiveClients;
|
|
||||||
_cleanupTimer.Dispose();
|
|
||||||
|
|
||||||
foreach (var bufferEntry in _buffers.Values)
|
_cleanupTimer.Elapsed -= CleanupInactiveClients;
|
||||||
|
_cleanupTimer.Stop();
|
||||||
|
_cleanupTimer.Dispose();
|
||||||
|
|
||||||
|
foreach (var entry in _buffers.Values)
|
||||||
|
{
|
||||||
|
lock (entry.SyncRoot)
|
||||||
{
|
{
|
||||||
lock (bufferEntry.SyncRoot)
|
entry.Clear(shrink: true);
|
||||||
{
|
|
||||||
bufferEntry.Buffer.Clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_buffers.Clear();
|
}
|
||||||
|
|
||||||
ProcessMessage = null;
|
_buffers.Clear();
|
||||||
ProcessTextMessage = null;
|
OnProcessMessage = null;
|
||||||
OnMessageError = null;
|
OnProcessTextMessage = null;
|
||||||
OnError = null;
|
OnMessageError = null;
|
||||||
}
|
OnError = null;
|
||||||
finally
|
|
||||||
{
|
GC.SuppressFinalize(this);
|
||||||
_isDisposed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static class StringExtensions
|
||||||
|
{
|
||||||
|
internal static bool Contains(this string? source, string toCheck, StringComparison comp) =>
|
||||||
|
source?.IndexOf(toCheck, comp) >= 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
266
EonaCat.Connections/Processors/JsonDataProcessorHelper.cs
Normal file
266
EonaCat.Connections/Processors/JsonDataProcessorHelper.cs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.Processors
|
||||||
|
{
|
||||||
|
internal static class JsonDataProcessorHelper
|
||||||
|
{
|
||||||
|
public const int MAX_CAP = 1024;
|
||||||
|
private const int MAX_SEGMENTS_PER_CALL = 1024;
|
||||||
|
|
||||||
|
internal static bool TryExtractCompleteJson(StringBuilder buffer, out string[] jsonArray, out string[] nonJsonText)
|
||||||
|
{
|
||||||
|
jsonArray = Array.Empty<string>();
|
||||||
|
nonJsonText = Array.Empty<string>();
|
||||||
|
|
||||||
|
if (buffer is null || buffer.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonList = new List<string>(capacity: 4);
|
||||||
|
var nonJsonList = new List<string>(capacity: 2);
|
||||||
|
|
||||||
|
int readPos = 0;
|
||||||
|
int segmentsProcessed = 0;
|
||||||
|
int bufferLen = buffer.Length;
|
||||||
|
|
||||||
|
while (readPos < bufferLen && segmentsProcessed < MAX_SEGMENTS_PER_CALL)
|
||||||
|
{
|
||||||
|
segmentsProcessed++;
|
||||||
|
|
||||||
|
// Skip non-JSON starting characters
|
||||||
|
if (buffer[readPos] != '{' && buffer[readPos] != '[')
|
||||||
|
{
|
||||||
|
int start = readPos;
|
||||||
|
while (readPos < bufferLen && buffer[readPos] != '{' && buffer[readPos] != '[')
|
||||||
|
{
|
||||||
|
readPos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
int len = readPos - start;
|
||||||
|
if (len > 0)
|
||||||
|
{
|
||||||
|
var segment = buffer.ToString(start, len).Trim();
|
||||||
|
if (segment.Length > 0)
|
||||||
|
{
|
||||||
|
nonJsonList.Add(segment);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have reached the end
|
||||||
|
if (readPos >= bufferLen)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pos = readPos;
|
||||||
|
int depth = 0;
|
||||||
|
bool inString = false;
|
||||||
|
bool escape = false;
|
||||||
|
int startIndex = pos;
|
||||||
|
bool complete = false;
|
||||||
|
|
||||||
|
for (; pos < bufferLen; pos++)
|
||||||
|
{
|
||||||
|
char c = buffer[pos];
|
||||||
|
|
||||||
|
if (inString)
|
||||||
|
{
|
||||||
|
if (escape)
|
||||||
|
{
|
||||||
|
escape = false;
|
||||||
|
}
|
||||||
|
else if (c == '\\')
|
||||||
|
{
|
||||||
|
escape = true;
|
||||||
|
}
|
||||||
|
else if (c == '"')
|
||||||
|
{
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '"':
|
||||||
|
inString = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '{':
|
||||||
|
case '[':
|
||||||
|
depth++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '}':
|
||||||
|
case ']':
|
||||||
|
depth--;
|
||||||
|
if (depth == 0)
|
||||||
|
{
|
||||||
|
// Completed JSON segment
|
||||||
|
pos++;
|
||||||
|
complete = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complete)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complete)
|
||||||
|
{
|
||||||
|
int length = pos - startIndex;
|
||||||
|
if (length <= 0)
|
||||||
|
{
|
||||||
|
// Should not happen, but just in case
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract candidate JSON segment
|
||||||
|
string candidateJson = buffer.ToString(startIndex, length);
|
||||||
|
|
||||||
|
// Clean internal non-JSON characters
|
||||||
|
var cleaned = StripInternalNonJson(candidateJson, out var extractedInside);
|
||||||
|
if (extractedInside != null && extractedInside.Length > 0)
|
||||||
|
{
|
||||||
|
nonJsonList.AddRange(extractedInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(cleaned))
|
||||||
|
{
|
||||||
|
jsonList.Add(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move readPos forward
|
||||||
|
readPos = pos;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Incomplete JSON segment; stop processing
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove processed part from buffer
|
||||||
|
if (readPos > 0)
|
||||||
|
{
|
||||||
|
buffer.Remove(0, readPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup buffer capacity if needed
|
||||||
|
if (buffer.Capacity > MAX_CAP && buffer.Length < 256)
|
||||||
|
{
|
||||||
|
buffer.Capacity = Math.Max(MAX_CAP, buffer.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonArray = jsonList.Count > 0 ? jsonList.ToArray() : Array.Empty<string>();
|
||||||
|
nonJsonText = nonJsonList.Count > 0 ? nonJsonList.ToArray() : Array.Empty<string>();
|
||||||
|
|
||||||
|
return jsonArray.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripInternalNonJson(string input, out string[] extractedTexts)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(input))
|
||||||
|
{
|
||||||
|
extractedTexts = Array.Empty<string>();
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan through the input, copying valid JSON parts to output,
|
||||||
|
List<char>? extractedChars = null;
|
||||||
|
var sbJson = new StringBuilder(input.Length);
|
||||||
|
bool inString = false;
|
||||||
|
bool escape = false;
|
||||||
|
int depth = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < input.Length; i++)
|
||||||
|
{
|
||||||
|
char c = input[i];
|
||||||
|
|
||||||
|
if (inString)
|
||||||
|
{
|
||||||
|
sbJson.Append(c);
|
||||||
|
if (escape)
|
||||||
|
{
|
||||||
|
escape = false;
|
||||||
|
}
|
||||||
|
else if (c == '\\')
|
||||||
|
{
|
||||||
|
escape = true;
|
||||||
|
}
|
||||||
|
else if (c == '"')
|
||||||
|
{
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '"':
|
||||||
|
inString = true;
|
||||||
|
sbJson.Append(c);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '{':
|
||||||
|
case '[':
|
||||||
|
depth++;
|
||||||
|
sbJson.Append(c);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '}':
|
||||||
|
case ']':
|
||||||
|
depth--;
|
||||||
|
sbJson.Append(c);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Outside JSON structures, only allow certain characters
|
||||||
|
if (depth > 0 || char.IsLetterOrDigit(c) || char.IsWhiteSpace(c) || ",:.-_".IndexOf(c) >= 0)
|
||||||
|
{
|
||||||
|
sbJson.Append(c);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
extractedChars ??= new List<char>(capacity: 4);
|
||||||
|
extractedChars.Add(c);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedChars != null && extractedChars.Count > 0)
|
||||||
|
{
|
||||||
|
// Convert char list to string array
|
||||||
|
var current = new string[extractedChars.Count];
|
||||||
|
for (int i = 0; i < extractedChars.Count; i++)
|
||||||
|
{
|
||||||
|
current[i] = extractedChars[i].ToString();
|
||||||
|
}
|
||||||
|
extractedTexts = current;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
extractedTexts = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
string result = sbJson.ToString().Trim();
|
||||||
|
|
||||||
|
// Recompute capacity if needed
|
||||||
|
if (sbJson.Capacity > MAX_CAP)
|
||||||
|
{
|
||||||
|
sbJson.Capacity = Math.Max(MAX_CAP, sbJson.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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 ProtocolType
|
|
||||||
{
|
|
||||||
TCP,
|
|
||||||
UDP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user