This commit is contained in:
2026-06-17 08:05:50 +02:00
committed by Jeroen Saey
parent 94119081bb
commit b410c033dd
36 changed files with 7905 additions and 2833 deletions
@@ -7,6 +7,14 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Content Remove="C:\Users\jesa\.nuget\packages\eonacat.json\2.0.6\contentFiles\any\net8.0\Monikers.imagemanifest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="EonaCat.Json" Version="2.2.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
</ItemGroup>
+3
View File
@@ -6,6 +6,9 @@ using System.Threading.Tasks;
namespace EonaCat.Connections.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.
// Root myDeserializedClass = JsonConvert.DeserializeObject<List<Root>>(myJsonResponse);
public class Contact
{
+8 -13
View File
@@ -2,12 +2,14 @@
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
using EonaCat.Connections.Models;
using EonaCat.Connections.Processors;
using System.Reflection;
using System.Text;
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
{
private const bool UseProcessor = true;
@@ -62,7 +64,7 @@ namespace EonaCat.Connections.Client.Example
{
foreach (var client in _clients)
{
await client.DisconnectClientAsync().ConfigureAwait(false);
await client.DisconnectAsync().ConfigureAwait(false);
}
break;
}
@@ -96,10 +98,9 @@ namespace EonaCat.Connections.Client.Example
};
processor.OnProcessMessage += (sender, e) =>
{
WriteToLog($"Processed JSON message from {e.ClientName} ({e.ClientEndpoint}): {e.RawData}");
WriteToLog($"Processed JSON message from {e.ClientName} ({e.ClientEndpoint}): {e.Data}");
};
processor.MaxAllowedBufferSize = 50 * 1024 * 1024; // 10 MB
processor.MaxMessagesPerBatch = 5;
var json = _jsonContent;
while (true)
@@ -155,7 +156,6 @@ namespace EonaCat.Connections.Client.Example
Protocol = ProtocolType.TCP,
Host = SERVER_IP,
Port = 1111,
UseSsl = false,
UseAesEncryption = false,
EnableHeartbeat = IsHeartBeatEnabled,
AesPassword = "EonaCat.Connections.Password",
@@ -175,16 +175,11 @@ namespace EonaCat.Connections.Client.Example
if (UseProcessor)
{
_clientsProcessors[client] = new JsonDataProcessor<List<Root>>();
_clientsProcessors[client].OnMessageError += (sender, e) =>
_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}");
@@ -192,7 +187,7 @@ namespace EonaCat.Connections.Client.Example
_clientsProcessors[client].OnProcessMessage += (sender, e) =>
{
ProcessMessage(e.RawData, e.ClientName, e.ClientEndpoint ?? "Unknown endpoint");
ProcessMessage(e.Data, e.ClientName, e.ClientEndpoint ?? "Unknown endpoint");
};
}
@@ -200,7 +195,7 @@ namespace EonaCat.Connections.Client.Example
{
if (UseProcessor)
{
_clientsProcessors[client].Process(e.StringData, clientName: e.Nickname);
_clientsProcessors[client].Process(e.StringData, currentClientName: e.Nickname);
return;
}
else
@@ -7,6 +7,14 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Content Remove="C:\Users\jesa\.nuget\packages\eonacat.json\2.0.6\contentFiles\any\net8.0\Monikers.imagemanifest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="EonaCat.Json" Version="2.2.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
</ItemGroup>
+4 -5
View File
@@ -1,11 +1,11 @@
// 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.
using EonaCat.Connections.Models;
using EonaCat.Connections.Models;
using System.Reflection;
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
{
private const bool IsHeartBeatEnabled = false;
@@ -52,7 +52,6 @@ namespace EonaCat.Connections.Server.Example
Protocol = ProtocolType.TCP,
Host = "0.0.0.0",
Port = 1111,
UseSsl = false,
UseAesEncryption = false,
MaxConnections = 100000,
AesPassword = "EonaCat.Connections.Password",
+4 -10
View File
@@ -1,14 +1,8 @@
// 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.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
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.
internal enum BufferSizeMaximum
{
Minimal = 8192,
+4 -4
View File
@@ -1,8 +1,8 @@
// 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.
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 enum DisconnectReason
{
Unknown,
@@ -39,9 +39,9 @@
</ItemGroup>
<ItemGroup>
<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="EonaCat.Json" Version="2.2.3" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Primitives" Version="10.0.9" />
<PackageReference Include="System.Buffers" Version="4.6.1" />
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
</ItemGroup>
@@ -1,4 +1,5 @@
using System.Net;
using System.Text;
// 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.
@@ -7,13 +8,13 @@ namespace EonaCat.Connections
{
public class DataReceivedEventArgs : EventArgs
{
public string ClientId { get; internal set; }
public byte[] Data { get; internal set; }
public string StringData { get; internal set; }
public string ClientId { get; set; }
public byte[] Data { get; set; }
public string StringData => Data != null ? Encoding.UTF8.GetString(Data) : string.Empty;
public bool IsBinary { get; internal set; }
public DateTime Timestamp { get; internal set; } = DateTime.UtcNow;
public IPEndPoint RemoteEndPoint { get; internal set; }
public string Nickname { get; internal set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public IPEndPoint RemoteEndPoint { get; set; }
public string Nickname { get; set; }
public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname);
}
}
@@ -1,8 +1,8 @@
// 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.
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 string ClientId { get; set; }
@@ -0,0 +1,30 @@
using System.Net;
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 IdleClientEventArgs : EventArgs
{
public IdleClientEventArgs() { }
public IdleClientEventArgs(double idleTimeoutSeconds, NetworkClient networkClient, string message)
{
IdleTimeoutSeconds = idleTimeoutSeconds;
NetworkClient = networkClient;
Message = message;
}
public double IdleTimeoutSeconds { get; set; }
public NetworkClient? NetworkClient { get; set; }
public string? ClientId { get; set; }
public string? Nickname { get; set; }
public IPEndPoint? RemoteEndPoint { get; set; }
public double IdleTimeSeconds { get; set; }
public bool HasClient => NetworkClient != null;
public bool IsIdle => IdleTimeoutSeconds > 0 || IdleTimeSeconds > 0;
public bool HasMessage => !string.IsNullOrEmpty(Message);
public string? Message { get; set; }
}
}
@@ -0,0 +1,24 @@
using EonaCat.Connections.Models;
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 IdleEventArgs : EventArgs
{
public IdleEventArgs(double idleTimeoutSeconds, Connection connection, string message)
{
IdleTimeoutSeconds = idleTimeoutSeconds;
Connection = connection;
Message = message;
}
public double IdleTimeoutSeconds { get; }
public Connection Connection { get; }
public bool HasConnection => Connection != null;
public bool IsIdle => IdleTimeoutSeconds > 0;
public bool HasMessage => !string.IsNullOrEmpty(Message);
public string Message { get; }
}
}
@@ -1,10 +1,10 @@
using System.Net;
// 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.
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 PingEventArgs : EventArgs
{
public string Id { get; set; }
+18 -20
View File
@@ -1,11 +1,11 @@
using System.Security.Cryptography;
using System.Text;
// 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.
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
{
// 256-bit salt
@@ -25,27 +25,21 @@ namespace EonaCat.Connections.Helpers
private static readonly byte[] KeyConfirmationLabel = Encoding.UTF8.GetBytes("KEYCONFIRMATION");
public static async Task<int> EncryptDataAsync(byte[] buffer, int bytesToSend, Aes aes)
{
using (var encryptor = aes.CreateEncryptor())
{
byte[] encrypted = await Task.Run(() => encryptor.TransformFinalBlock(buffer, 0, bytesToSend)).ConfigureAwait(false);
// AES block size for PKCS7 padding
public static int MaxEncryptionOverhead => 16;
Buffer.BlockCopy(encrypted, 0, buffer, 0, encrypted.Length);
return encrypted.Length;
}
public static Task<byte[]> EncryptDataAsync(byte[] data, int length, Aes aes)
{
using var encryptor = aes.CreateEncryptor();
byte[] encrypted = encryptor.TransformFinalBlock(data, 0, length);
return Task.FromResult(encrypted);
}
public static async Task<int> DecryptDataAsync(byte[] buffer, int bytesToSend, Aes aes)
public static Task<byte[]> DecryptDataAsync(byte[] data, int length, Aes aes)
{
using (var decryptor = aes.CreateDecryptor())
{
byte[] decrypted = await Task.Run(() => decryptor.TransformFinalBlock(buffer, 0, bytesToSend)).ConfigureAwait(false);
Buffer.BlockCopy(decrypted, 0, buffer, 0, decrypted.Length);
return decrypted.Length;
}
using var decryptor = aes.CreateDecryptor();
byte[] decrypted = decryptor.TransformFinalBlock(data, 0, length);
return Task.FromResult(decrypted);
}
public static async Task<Aes> SendAesKeyAsync(Stream stream, Aes aes, string password)
@@ -230,6 +224,9 @@ namespace EonaCat.Connections.Helpers
return false;
}
#if NET5_0_OR_GREATER
return CryptographicOperations.FixedTimeEquals(firstByteArray, secondByteArray);
#else
int difference = 0;
for (int i = 0; i < firstByteArray.Length; i++)
{
@@ -237,6 +234,7 @@ namespace EonaCat.Connections.Helpers
}
return difference == 0;
#endif
}
}
}
@@ -0,0 +1,325 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace EonaCat.Connections.Helpers
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
/// <summary>
/// A lightweight HTTP server that exposes health and status information via a REST API.
/// Listens on a configurable port (use 0 for a random available port).
/// </summary>
public class HealthApiServer : IDisposable
{
private TcpListener _listener;
private CancellationTokenSource _cts;
private Task _listenTask;
private volatile bool _running;
private readonly Func<string> _getHealthJson;
private readonly Func<string> _getStatusJson;
/// <summary>
/// Gets the actual port the server is listening on.
/// Returns 0 if the server is not started.
/// </summary>
public int Port { get; private set; }
/// <summary>
/// Gets whether the server is currently running.
/// </summary>
public bool IsRunning => _running;
/// <summary>
/// Creates a new HealthApiServer.
/// </summary>
/// <param name="getHealthJson">Callback that returns the JSON for the /health endpoint.</param>
/// <param name="getStatusJson">Callback that returns the JSON for the /status endpoint.</param>
public HealthApiServer(Func<string> getHealthJson, Func<string> getStatusJson)
{
_getHealthJson = getHealthJson ?? throw new ArgumentNullException(nameof(getHealthJson));
_getStatusJson = getStatusJson ?? throw new ArgumentNullException(nameof(getStatusJson));
}
/// <summary>
/// Starts the HTTP health API server.
/// </summary>
/// <param name="port">Port to listen on. Use 0 for a random available port.</param>
/// <param name="bindAddress">IP address to bind to. Use <c>IPAddress.Any</c> for Docker/container environments. Defaults to <c>IPAddress.Loopback</c>.</param>
public void Start(int port = 0, IPAddress bindAddress = null)
{
if (_running)
{
return;
}
_cts?.Dispose();
_cts = new CancellationTokenSource();
_running = true;
_listener = new TcpListener(bindAddress ?? IPAddress.Loopback, port);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
var token = _cts.Token;
_listenTask = Task.Run(async () => await AcceptLoopAsync(token).ConfigureAwait(false), token);
}
/// <summary>
/// Stops the HTTP health API server.
/// </summary>
public void Stop()
{
if (!_running)
{
return;
}
_running = false;
_cts?.Cancel();
try
{
_listener?.Stop();
}
catch
{
// Swallow
}
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
}
Port = 0;
}
private async Task AcceptLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
TcpClient client = null;
try
{
client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false);
_ = Task.Run(() => HandleRequestAsync(client), token);
}
catch (ObjectDisposedException)
{
break;
}
catch (SocketException)
{
break;
}
catch
{
// Swallow individual connection errors to keep the loop alive
}
}
}
private async Task HandleRequestAsync(TcpClient client)
{
try
{
using (client)
{
client.ReceiveTimeout = 5000;
client.SendTimeout = 5000;
using var stream = client.GetStream();
var requestLine = await ReadRequestLineAsync(stream).ConfigureAwait(false);
if (string.IsNullOrEmpty(requestLine))
{
await WriteResponseAsync(stream, 400, "Bad Request", "text/plain", "Bad Request").ConfigureAwait(false);
return;
}
var parts = requestLine.Split(' ');
if (parts.Length < 2)
{
await WriteResponseAsync(stream, 400, "Bad Request", "text/plain", "Bad Request").ConfigureAwait(false);
return;
}
var method = parts[0].ToUpperInvariant();
var path = parts[1].Split('?')[0].TrimEnd('/').ToLowerInvariant();
if (method != "GET")
{
await WriteResponseAsync(stream, 405, "Method Not Allowed", "text/plain", "Method Not Allowed").ConfigureAwait(false);
return;
}
// Drain remaining headers
await DrainHeadersAsync(stream).ConfigureAwait(false);
switch (path)
{
case "" or "/":
var indexJson = "{\"endpoints\":[\"/health\",\"/status\"]}";
await WriteResponseAsync(stream, 200, "OK", "application/json", indexJson).ConfigureAwait(false);
break;
case "/health":
var healthJson = SafeGetJson(_getHealthJson);
await WriteResponseAsync(stream, 200, "OK", "application/json", healthJson).ConfigureAwait(false);
break;
case "/status":
var statusJson = SafeGetJson(_getStatusJson);
await WriteResponseAsync(stream, 200, "OK", "application/json", statusJson).ConfigureAwait(false);
break;
default:
await WriteResponseAsync(stream, 404, "Not Found", "text/plain", "Not Found").ConfigureAwait(false);
break;
}
}
}
catch
{
// Swallow individual request errors
}
}
private static string SafeGetJson(Func<string> getter)
{
try
{
return getter() ?? "{}";
}
catch (Exception ex)
{
return "{\"error\":" + JsonEscape(ex.Message) + "}";
}
}
private static async Task<string> ReadRequestLineAsync(NetworkStream stream)
{
var sb = new StringBuilder();
var buffer = new byte[1];
var prev = (byte)0;
while (sb.Length < 8192)
{
int read = await stream.ReadAsync(buffer, 0, 1).ConfigureAwait(false);
if (read == 0)
{
break;
}
var b = buffer[0];
if (b == (byte)'\n')
{
break;
}
if (b != (byte)'\r')
{
sb.Append((char)b);
}
prev = b;
}
return sb.ToString();
}
private static async Task DrainHeadersAsync(NetworkStream stream)
{
var sb = new StringBuilder();
var buffer = new byte[1];
int consecutiveNewlines = 0;
while (consecutiveNewlines < 2)
{
int read = await stream.ReadAsync(buffer, 0, 1).ConfigureAwait(false);
if (read == 0)
{
break;
}
if (buffer[0] == (byte)'\n')
{
consecutiveNewlines++;
}
else if (buffer[0] != (byte)'\r')
{
consecutiveNewlines = 0;
}
}
}
private static async Task WriteResponseAsync(NetworkStream stream, int statusCode, string statusText, string contentType, string body)
{
var bodyBytes = Encoding.UTF8.GetBytes(body);
var header = $"HTTP/1.1 {statusCode} {statusText}\r\n" +
$"Content-Type: {contentType}; charset=utf-8\r\n" +
$"Content-Length: {bodyBytes.Length}\r\n" +
"Access-Control-Allow-Origin: *\r\n" +
"Connection: close\r\n" +
"X-Content-Type-Options: nosniff\r\n" +
"Cache-Control: no-store\r\n" +
"\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await stream.WriteAsync(headerBytes, 0, headerBytes.Length).ConfigureAwait(false);
await stream.WriteAsync(bodyBytes, 0, bodyBytes.Length).ConfigureAwait(false);
await stream.FlushAsync().ConfigureAwait(false);
}
/// <summary>
/// Escapes a string value for safe embedding in JSON output.
/// </summary>
public static string JsonEscape(string value)
{
if (value == null)
{
return "null";
}
var sb = new StringBuilder(value.Length + 2);
sb.Append('"');
foreach (var c in value)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < ' ')
{
sb.Append("\\u");
sb.Append(((int)c).ToString("x4"));
}
else
{
sb.Append(c);
}
break;
}
}
sb.Append('"');
return sb.ToString();
}
public void Dispose()
{
Stop();
_cts?.Dispose();
}
}
}
@@ -0,0 +1,652 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net.NetworkInformation;
using System.Text;
namespace EonaCat.Connections.Helpers
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
public class NetworkMonitor : IDisposable
{
private readonly string _targetHost;
private readonly int _pingIntervalMs;
private readonly int _maxHistory;
private readonly ConcurrentQueue<NetworkSample> _samples = new ConcurrentQueue<NetworkSample>();
private CancellationTokenSource _cts;
private Task _monitorTask;
private volatile bool _isRunning;
private CancellationTokenSource _autoReportCts;
private Task _autoReportTask;
private volatile bool _autoReportRunning;
public event EventHandler<NetworkOutageEventArgs> OnOutageDetected;
public event EventHandler<NetworkOutageEventArgs> OnOutageRecovered;
public event EventHandler<NetworkSample> OnSampleRecorded;
public bool IsRunning => _isRunning;
public string TargetHost => _targetHost;
/// <summary>
/// Gets whether automatic HTML report generation is currently running.
/// </summary>
public bool IsAutoHtmlReportRunning => _autoReportRunning;
public NetworkMonitor(string targetHost = "127.0.0.1", int pingIntervalMs = 5000, int maxHistory = 1000)
{
_targetHost = targetHost;
_pingIntervalMs = Math.Max(1000, pingIntervalMs);
_maxHistory = Math.Max(10, maxHistory);
}
public void Start()
{
if (_isRunning)
{
return;
}
_cts?.Dispose();
_cts = new CancellationTokenSource();
_isRunning = true;
_monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token));
}
public void Stop()
{
_isRunning = false;
_cts?.Cancel();
try
{
_monitorTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
}
}
private async Task MonitorLoopAsync(CancellationToken token)
{
bool wasReachable = true;
DateTime? outageStartedAt = null;
while (!token.IsCancellationRequested)
{
var sample = await CollectSampleAsync().ConfigureAwait(false);
RecordSample(sample);
OnSampleRecorded?.Invoke(this, sample);
if (!sample.IsReachable && wasReachable)
{
outageStartedAt = sample.Timestamp;
OnOutageDetected?.Invoke(this, new NetworkOutageEventArgs
{
TargetHost = _targetHost,
OutageStartedAt = sample.Timestamp,
Sample = sample
});
}
else if (sample.IsReachable && !wasReachable && outageStartedAt.HasValue)
{
OnOutageRecovered?.Invoke(this, new NetworkOutageEventArgs
{
TargetHost = _targetHost,
OutageStartedAt = outageStartedAt.Value,
OutageEndedAt = sample.Timestamp,
OutageDuration = sample.Timestamp - outageStartedAt.Value,
Sample = sample
});
outageStartedAt = null;
}
wasReachable = sample.IsReachable;
try
{
await Task.Delay(_pingIntervalMs, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
private async Task<NetworkSample> CollectSampleAsync()
{
var sample = new NetworkSample
{
Timestamp = DateTime.UtcNow,
TargetHost = _targetHost
};
try
{
using (var ping = new Ping())
{
var sw = Stopwatch.StartNew();
var reply = await ping.SendPingAsync(_targetHost, 5000).ConfigureAwait(false);
sw.Stop();
sample.RoundTripTimeMs = reply.RoundtripTime;
sample.MeasuredLatencyMs = sw.ElapsedMilliseconds;
sample.IsReachable = reply.Status == IPStatus.Success;
sample.PingStatus = reply.Status;
}
}
catch (PingException ex)
{
sample.IsReachable = false;
sample.Error = ex.InnerException?.Message ?? ex.Message;
}
catch (Exception ex)
{
sample.IsReachable = false;
sample.Error = ex.Message;
}
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
long totalBytesReceived = 0;
long totalBytesSent = 0;
foreach (var ni in interfaces)
{
if (ni.OperationalStatus != OperationalStatus.Up)
{
continue;
}
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback)
{
continue;
}
var stats = ni.GetIPv4Statistics();
totalBytesReceived += stats.BytesReceived;
totalBytesSent += stats.BytesSent;
}
sample.SystemBytesReceived = totalBytesReceived;
sample.SystemBytesSent = totalBytesSent;
}
catch
{
// Network interface stats may not be available on all platforms
}
return sample;
}
private void RecordSample(NetworkSample sample)
{
_samples.Enqueue(sample);
while (_samples.Count > _maxHistory)
{
_samples.TryDequeue(out _);
}
}
public IReadOnlyList<NetworkSample> GetSamples()
{
return _samples.ToArray();
}
public IReadOnlyList<NetworkSample> GetRecentSamples(int count)
{
var all = _samples.ToArray();
int skip = Math.Max(0, all.Length - count);
var result = new NetworkSample[Math.Min(count, all.Length)];
Array.Copy(all, skip, result, 0, result.Length);
return result;
}
public NetworkHealthReport GetHealthReport()
{
var samples = _samples.ToArray();
var report = new NetworkHealthReport
{
TargetHost = _targetHost,
GeneratedAt = DateTime.UtcNow,
TotalSamples = samples.Length
};
if (samples.Length == 0)
{
return report;
}
var reachable = new List<NetworkSample>();
var unreachable = new List<NetworkSample>();
foreach (var s in samples)
{
if (s.IsReachable)
{
reachable.Add(s);
}
else
{
unreachable.Add(s);
}
}
report.SuccessfulPings = reachable.Count;
report.FailedPings = unreachable.Count;
report.UptimePercentage = samples.Length > 0
? (double)reachable.Count / samples.Length * 100.0
: 0;
if (reachable.Count > 0)
{
long totalRtt = 0;
long minRtt = long.MaxValue;
long maxRtt = long.MinValue;
foreach (var s in reachable)
{
totalRtt += s.RoundTripTimeMs;
if (s.RoundTripTimeMs < minRtt)
{
minRtt = s.RoundTripTimeMs;
}
if (s.RoundTripTimeMs > maxRtt)
{
maxRtt = s.RoundTripTimeMs;
}
}
report.AverageLatencyMs = (double)totalRtt / reachable.Count;
report.MinLatencyMs = minRtt;
report.MaxLatencyMs = maxRtt;
// Calculate jitter (standard deviation of latency)
double sumSquaredDiff = 0;
foreach (var s in reachable)
{
var diff = s.RoundTripTimeMs - report.AverageLatencyMs;
sumSquaredDiff += diff * diff;
}
report.JitterMs = Math.Sqrt(sumSquaredDiff / reachable.Count);
}
// Detect outage windows
bool inOutage = false;
DateTime outageStart = DateTime.MinValue;
foreach (var s in samples)
{
if (!s.IsReachable && !inOutage)
{
inOutage = true;
outageStart = s.Timestamp;
}
else if (s.IsReachable && inOutage)
{
inOutage = false;
report.OutageWindows.Add(new OutageWindow
{
Start = outageStart,
End = s.Timestamp,
Duration = s.Timestamp - outageStart
});
}
}
if (inOutage)
{
report.OutageWindows.Add(new OutageWindow
{
Start = outageStart,
End = DateTime.UtcNow,
Duration = DateTime.UtcNow - outageStart,
IsOngoing = true
});
}
// Bandwidth estimate between last two samples
if (samples.Length >= 2)
{
var prev = samples[samples.Length - 2];
var curr = samples[samples.Length - 1];
var elapsed = (curr.Timestamp - prev.Timestamp).TotalSeconds;
if (elapsed > 0 && prev.SystemBytesReceived > 0 && curr.SystemBytesReceived > 0)
{
report.CurrentReceiveBytesPerSecond = (curr.SystemBytesReceived - prev.SystemBytesReceived) / elapsed;
report.CurrentSendBytesPerSecond = (curr.SystemBytesSent - prev.SystemBytesSent) / elapsed;
}
}
report.IsStable = report.UptimePercentage >= 99.0
&& report.JitterMs < 50
&& report.OutageWindows.Count(o => o.IsOngoing) == 0;
return report;
}
public void Dispose()
{
Stop();
StopAutoHtmlReport();
_cts?.Dispose();
_autoReportCts?.Dispose();
}
/// <summary>
/// Starts periodic automatic generation of the network health HTML report.
/// </summary>
/// <param name="outputDirectory">Directory where the HTML file is written.</param>
/// <param name="intervalSeconds">Interval in seconds between report generations.</param>
/// <param name="fileName">The HTML file name.</param>
public void StartAutoHtmlReport(string outputDirectory, int intervalSeconds = 60, string fileName = "status-network.html")
{
if (_autoReportRunning)
{
return;
}
_autoReportCts?.Dispose();
_autoReportCts = new CancellationTokenSource();
_autoReportRunning = true;
var interval = TimeSpan.FromSeconds(Math.Max(5, intervalSeconds));
var token = _autoReportCts.Token;
_autoReportTask = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
try
{
if (!Directory.Exists(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
var report = GetHealthReport();
var html = report.GenerateHtmlReport();
var filePath = Path.Combine(outputDirectory, fileName);
File.WriteAllText(filePath, html, Encoding.UTF8);
}
catch
{
// Swallow to keep the loop alive
}
try
{
await Task.Delay(interval, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}, token);
}
/// <summary>
/// Stops the automatic HTML report generation.
/// </summary>
public void StopAutoHtmlReport()
{
if (!_autoReportRunning)
{
return;
}
_autoReportRunning = false;
_autoReportCts?.Cancel();
try
{
_autoReportTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
}
}
}
public class NetworkSample
{
public DateTime Timestamp { get; set; }
public string TargetHost { get; set; }
public bool IsReachable { get; set; }
public long RoundTripTimeMs { get; set; }
public long MeasuredLatencyMs { get; set; }
public IPStatus PingStatus { get; set; }
public string Error { get; set; }
public long SystemBytesReceived { get; set; }
public long SystemBytesSent { get; set; }
}
public class NetworkOutageEventArgs : EventArgs
{
public string TargetHost { get; set; }
public DateTime OutageStartedAt { get; set; }
public DateTime? OutageEndedAt { get; set; }
public TimeSpan? OutageDuration { get; set; }
public NetworkSample Sample { get; set; }
}
public class OutageWindow
{
public DateTime Start { get; set; }
public DateTime End { get; set; }
public TimeSpan Duration { get; set; }
public bool IsOngoing { get; set; }
}
public class NetworkHealthReport
{
public string TargetHost { get; set; }
public DateTime GeneratedAt { get; set; }
public int TotalSamples { get; set; }
public int SuccessfulPings { get; set; }
public int FailedPings { get; set; }
public double UptimePercentage { get; set; }
public double AverageLatencyMs { get; set; }
public long MinLatencyMs { get; set; }
public long MaxLatencyMs { get; set; }
public double JitterMs { get; set; }
public double CurrentReceiveBytesPerSecond { get; set; }
public double CurrentSendBytesPerSecond { get; set; }
public bool IsStable { get; set; }
public List<OutageWindow> OutageWindows { get; set; } = new List<OutageWindow>();
public string GetSummary()
{
var sb = new StringBuilder();
sb.AppendLine($"=== Network Health Report ===");
sb.AppendLine($"Target: {TargetHost}");
sb.AppendLine($"Generated: {GeneratedAt:O}");
sb.AppendLine($"Samples: {TotalSamples}");
sb.AppendLine($"Success: {SuccessfulPings} / Failed: {FailedPings}");
sb.AppendLine($"Uptime: {UptimePercentage:F2}%");
sb.AppendLine($"Latency (avg): {AverageLatencyMs:F1} ms");
sb.AppendLine($"Latency (min): {MinLatencyMs} ms / (max): {MaxLatencyMs} ms");
sb.AppendLine($"Jitter: {JitterMs:F1} ms");
sb.AppendLine($"Recv BW: {CurrentReceiveBytesPerSecond:F0} B/s");
sb.AppendLine($"Send BW: {CurrentSendBytesPerSecond:F0} B/s");
sb.AppendLine($"Stable: {(IsStable ? "Yes" : "No")}");
sb.AppendLine($"Outages: {OutageWindows.Count}");
foreach (var o in OutageWindows)
{
var status = o.IsOngoing ? " (ONGOING)" : string.Empty;
sb.AppendLine($" {o.Start:HH:mm:ss} - {o.End:HH:mm:ss} ({o.Duration.TotalSeconds:F0}s){status}");
}
return sb.ToString();
}
public string GenerateHtmlReport(string title = "Network Health Report")
{
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{HtmlEncode(title)}</title>");
sb.AppendLine("<style>");
sb.AppendLine("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; color: #333; }");
sb.AppendLine("h1 { color: #1a1a2e; }");
sb.AppendLine("h2 { color: #16213e; margin-top: 30px; }");
sb.AppendLine(".container { max-width: 1200px; margin: 0 auto; }");
sb.AppendLine(".summary-cards { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }");
sb.AppendLine(".card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 180px; }");
sb.AppendLine(".card .label { font-size: 0.85em; color: #666; text-transform: uppercase; }");
sb.AppendLine(".card .value { font-size: 1.8em; font-weight: bold; margin-top: 4px; }");
sb.AppendLine(".card.ok .value { color: #27ae60; }");
sb.AppendLine(".card.warn .value { color: #f39c12; }");
sb.AppendLine(".card.error .value { color: #e74c3c; }");
sb.AppendLine(".status-banner { padding: 16px 24px; border-radius: 8px; margin-bottom: 24px; font-size: 1.2em; font-weight: bold; }");
sb.AppendLine(".status-stable { background: #d4edda; color: #155724; }");
sb.AppendLine(".status-unstable { background: #f8d7da; color: #721c24; }");
sb.AppendLine("table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
sb.AppendLine("th { background: #1a1a2e; color: #fff; padding: 12px 16px; text-align: left; font-size: 0.85em; text-transform: uppercase; }");
sb.AppendLine("td { padding: 10px 16px; border-bottom: 1px solid #eee; font-size: 0.9em; }");
sb.AppendLine("tr:hover td { background: #f0f4ff; }");
sb.AppendLine("tr.ongoing td { background: #fff3cd; }");
sb.AppendLine(".progress-bg { background: #eee; border-radius: 6px; overflow: hidden; height: 22px; }");
sb.AppendLine(".progress-bar { height: 100%; border-radius: 6px; text-align: center; color: #fff; font-size: 0.8em; line-height: 22px; }");
sb.AppendLine(".no-issues { text-align: center; padding: 60px 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
sb.AppendLine(".no-issues .icon { font-size: 3em; }");
sb.AppendLine(".no-issues p { color: #27ae60; font-size: 1.2em; }");
sb.AppendLine(".footer { margin-top: 30px; text-align: center; color: #999; font-size: 0.85em; }");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<div class=\"container\">");
sb.AppendLine($"<h1>{HtmlEncode(title)}</h1>");
// Status banner
if (TotalSamples > 0)
{
var bannerClass = IsStable ? "status-stable" : "status-unstable";
var bannerText = IsStable ? "&#10004; Network is Stable" : "&#9888; Network is Unstable";
sb.AppendLine($"<div class=\"status-banner {bannerClass}\">{bannerText}</div>");
}
// Summary cards
var uptimeClass = UptimePercentage >= 99 ? "ok" : UptimePercentage >= 95 ? "warn" : "error";
var latencyClass = AverageLatencyMs < 50 ? "ok" : AverageLatencyMs < 150 ? "warn" : "error";
var jitterClass = JitterMs < 20 ? "ok" : JitterMs < 50 ? "warn" : "error";
sb.AppendLine("<div class=\"summary-cards\">");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Target</div><div class=\"value\" style=\"font-size:1em\">{HtmlEncode(TargetHost)}</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Samples</div><div class=\"value\">{TotalSamples}</div></div>");
sb.AppendLine($"<div class=\"card {uptimeClass}\"><div class=\"label\">Uptime</div><div class=\"value\">{UptimePercentage:F1}%</div></div>");
sb.AppendLine($"<div class=\"card {latencyClass}\"><div class=\"label\">Avg Latency</div><div class=\"value\">{AverageLatencyMs:F1} ms</div></div>");
sb.AppendLine($"<div class=\"card {jitterClass}\"><div class=\"label\">Jitter</div><div class=\"value\">{JitterMs:F1} ms</div></div>");
sb.AppendLine($"<div class=\"card {(FailedPings > 0 ? "error" : "ok")}\"><div class=\"label\">Failed Pings</div><div class=\"value\">{FailedPings}</div></div>");
sb.AppendLine("</div>");
// Uptime progress bar
sb.AppendLine("<h2>Uptime</h2>");
var barColor = UptimePercentage >= 99 ? "#27ae60" : UptimePercentage >= 95 ? "#f39c12" : "#e74c3c";
var barWidth = Math.Max(0, Math.Min(100, UptimePercentage));
sb.AppendLine($"<div class=\"progress-bg\"><div class=\"progress-bar\" style=\"width:{barWidth:F1}%;background:{barColor}\">{UptimePercentage:F2}%</div></div>");
// Latency / Bandwidth
sb.AppendLine("<h2>Performance</h2>");
sb.AppendLine("<div class=\"summary-cards\">");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Min Latency</div><div class=\"value\">{MinLatencyMs} ms</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Max Latency</div><div class=\"value\">{MaxLatencyMs} ms</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Recv Bandwidth</div><div class=\"value\" style=\"font-size:1em\">{FormatBytes(CurrentReceiveBytesPerSecond)}/s</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Send Bandwidth</div><div class=\"value\" style=\"font-size:1em\">{FormatBytes(CurrentSendBytesPerSecond)}/s</div></div>");
sb.AppendLine("</div>");
// Outage windows
sb.AppendLine("<h2>Outage History</h2>");
if (OutageWindows.Count == 0)
{
sb.AppendLine("<div class=\"no-issues\">");
sb.AppendLine("<div class=\"icon\">&#10004;</div>");
sb.AppendLine("<p>No outages detected.</p>");
sb.AppendLine("</div>");
}
else
{
sb.AppendLine("<table>");
sb.AppendLine("<thead><tr><th>#</th><th>Start (UTC)</th><th>End (UTC)</th><th>Duration</th><th>Status</th></tr></thead>");
sb.AppendLine("<tbody>");
for (int i = 0; i < OutageWindows.Count; i++)
{
var o = OutageWindows[i];
var rowClass = o.IsOngoing ? "ongoing" : "";
var statusText = o.IsOngoing
? "<span style=\"color:#e74c3c;font-weight:bold\">ONGOING</span>"
: "<span style=\"color:#27ae60\">Recovered</span>";
sb.AppendLine($"<tr class=\"{rowClass}\">");
sb.AppendLine($"<td>{i + 1}</td>");
sb.AppendLine($"<td>{o.Start:yyyy-MM-dd HH:mm:ss}</td>");
sb.AppendLine($"<td>{(o.IsOngoing ? "-" : o.End.ToString("yyyy-MM-dd HH:mm:ss"))}</td>");
sb.AppendLine($"<td>{o.Duration.TotalSeconds:F0}s</td>");
sb.AppendLine($"<td>{statusText}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
}
sb.AppendLine($"<div class=\"footer\">Generated at {GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC &mdash; EonaCat.Connections</div>");
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
public void SaveHtmlReport(string filePath, string title = "Network Health Report")
{
var html = GenerateHtmlReport(title);
File.WriteAllText(filePath, html, Encoding.UTF8);
}
private static string FormatBytes(double bytes)
{
if (bytes >= 1_073_741_824)
{
return $"{bytes / 1_073_741_824:F2} GB";
}
if (bytes >= 1_048_576)
{
return $"{bytes / 1_048_576:F2} MB";
}
if (bytes >= 1024)
{
return $"{bytes / 1024:F2} KB";
}
return $"{bytes:F0} B";
}
private static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
}
}
@@ -0,0 +1,263 @@
using System;
using System.Diagnostics;
using System.Net.Security;
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.
/// <summary>
/// Helper class for tracking and diagnosing SSL/TLS handshake performance and errors.
/// </summary>
public class SslHandshakeDiagnostics
{
private readonly SslMetrics _metrics = new();
private readonly Stopwatch _totalStopwatch = new();
private Stopwatch? _stageStopwatch;
/// <summary>
/// Gets the current metrics object.
/// </summary>
public SslMetrics Metrics => _metrics;
/// <summary>
/// Starts tracking a new SSL handshake attempt.
/// </summary>
public void StartHandshake(string remoteEndPoint, bool mutualAuthRequired)
{
_totalStopwatch.Restart();
_metrics.StartTime = DateTime.UtcNow;
_metrics.RemoteEndPoint = remoteEndPoint;
_metrics.MutualAuthenticationRequired = mutualAuthRequired;
_metrics.AttemptCount++;
_metrics.IsSuccessful = false;
_metrics.FailureReason = null;
}
/// <summary>
/// Marks the start of a specific SSL stage (e.g., AuthenticateAsServer).
/// </summary>
public void StartStage(string stageName)
{
_stageStopwatch = Stopwatch.StartNew();
}
/// <summary>
/// Marks the end of a specific SSL stage and records the duration.
/// </summary>
public void EndStage(string stageName)
{
if (_stageStopwatch == null)
{
return;
}
_stageStopwatch.Stop();
var duration = _stageStopwatch.Elapsed;
switch (stageName.ToLower())
{
case "serverauth":
case "authenticateasserver":
_metrics.ServerAuthenticationDuration = duration;
break;
case "clientauth":
case "authenticateasclient":
_metrics.ClientAuthenticationDuration = duration;
break;
case "retrydelay":
if (_metrics.RetryDelayDuration == null)
{
_metrics.RetryDelayDuration = duration;
}
else
{
_metrics.RetryDelayDuration += duration;
}
break;
}
_stageStopwatch = null;
}
/// <summary>
/// Records successful SSL handshake completion and captures protocol details.
/// </summary>
public void RecordSuccess(SslStream sslStream)
{
_totalStopwatch.Stop();
_metrics.IsSuccessful = true;
_metrics.EndTime = DateTime.UtcNow;
_metrics.FailureReason = null;
if (sslStream != null)
{
try
{
_metrics.SslProtocolVersion = sslStream.SslProtocol.ToString();
_metrics.CipherAlgorithm = sslStream.CipherAlgorithm.ToString();
_metrics.HashAlgorithm = sslStream.HashAlgorithm.ToString();
_metrics.KeyExchangeAlgorithm = sslStream.KeyExchangeAlgorithm.ToString();
}
catch
{
// No cross-platform way to get these details, so ignore if not available.
}
}
_metrics.CumulativeDuration = _totalStopwatch.Elapsed;
}
/// <summary>
/// Records SSL handshake failure with error details.
/// </summary>
public void RecordFailure(Exception exception, bool isRecoverable)
{
_totalStopwatch.Stop();
_metrics.IsSuccessful = false;
_metrics.EndTime = DateTime.UtcNow;
_metrics.FailureReason = $"{exception?.GetType().Name ?? "Unknown"}: {exception?.Message ?? "No details"}";
_metrics.IsRecoverableFailure = isRecoverable;
_metrics.CumulativeDuration = _totalStopwatch.Elapsed;
}
/// <summary>
/// Analyzes the failure and determines if it's recoverable.
/// Non-recoverable failures: authentication failures, certificate errors, protocol mismatches
/// Recoverable failures: timeouts, network interruptions, EOF during handshake
/// </summary>
public static bool IsRecoverableFailure(Exception exception)
{
if (exception == null)
{
return false;
}
var message = exception.Message?.ToLower() ?? "";
var typeName = exception.GetType().Name.ToLower();
// Non-recoverable SSL authentication and certificate errors
if (typeName.Contains("authenticationexception"))
{
return false;
}
if (message.Contains("certificate rejected"))
{
return false;
}
if (message.Contains("certificate validation failed"))
{
return false;
}
if (message.Contains("certificate not trusted"))
{
return false;
}
if (message.Contains("certificate chain"))
{
return false;
}
if (message.Contains("sslhandshakefailure"))
{
return false;
}
if (message.Contains("the request was aborted"))
{
return false;
}
// Check for handshake_failure TLS alerts
if (message.Contains("handshake_failure"))
{
return false;
}
if (message.Contains("protocol_version"))
{
return false;
}
if (message.Contains("unsupported_certificate_type"))
{
return false;
}
// Recoverable I/O and network errors
if (message.Contains("0 bytes from the transport stream"))
{
return true;
}
if (message.Contains("unexpected eof"))
{
return true;
}
if (message.Contains("connection reset"))
{
return true;
}
if (message.Contains("connection aborted"))
{
return true;
}
if (message.Contains("timed out"))
{
return true;
}
if (message.Contains("timeout"))
{
return true;
}
if (message.Contains("network unreachable"))
{
return true;
}
if (message.Contains("connection refused"))
{
return true;
}
if (typeName.Contains("ioexception"))
{
return true;
}
if (typeName.Contains("socketexception"))
{
return true;
}
if (typeName.Contains("operationcanceledexception"))
{
return true;
}
if (exception.InnerException != null)
{
return IsRecoverableFailure(exception.InnerException);
}
// Default: assume recoverable for network-layer exceptions
return typeName.Contains("exception");
}
/// <summary>
/// Returns a diagnostic summary of the handshake attempt.
/// </summary>
public override string ToString() => _metrics.ToString();
}
}
+126
View File
@@ -0,0 +1,126 @@
using System;
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.
/// <summary>
/// Tracks metrics and timing information for SSL/TLS handshake stages.
/// </summary>
public class SslMetrics
{
/// <summary>
/// Gets the timestamp when the SSL handshake attempt started.
/// </summary>
public DateTime StartTime { get; set; }
/// <summary>
/// Gets the timestamp when the SSL handshake completed (success or final failure).
/// </summary>
public DateTime? EndTime { get; set; }
/// <summary>
/// Gets the total duration of the SSL handshake attempt.
/// </summary>
public TimeSpan? TotalDuration => EndTime.HasValue ? EndTime.Value - StartTime : null;
/// <summary>
/// Gets the duration of the AuthenticateAsServer call (server-side).
/// </summary>
public TimeSpan? ServerAuthenticationDuration { get; set; }
/// <summary>
/// Gets the duration of the AuthenticateAsClient call (client-side).
/// </summary>
public TimeSpan? ClientAuthenticationDuration { get; set; }
/// <summary>
/// Gets the time spent retrying SSL handshakes after failures.
/// </summary>
public TimeSpan? RetryDelayDuration { get; set; }
/// <summary>
/// Gets the total time spent in all SSL handshake attempts including retries.
/// </summary>
public TimeSpan? CumulativeDuration { get; set; }
/// <summary>
/// Gets the number of SSL handshake attempts made.
/// </summary>
public int AttemptCount { get; set; }
/// <summary>
/// Gets the SSL protocol version negotiated (e.g., "Tls13", "Tls12").
/// </summary>
public string? SslProtocolVersion { get; set; }
/// <summary>
/// Gets the cipher algorithm used (e.g., "Aes256", "ChaCha20").
/// </summary>
public string? CipherAlgorithm { get; set; }
/// <summary>
/// Gets the hash algorithm used (e.g., "Sha256").
/// </summary>
public string? HashAlgorithm { get; set; }
/// <summary>
/// Gets the key exchange algorithm used (e.g., "Ecdh").
/// </summary>
public string? KeyExchangeAlgorithm { get; set; }
/// <summary>
/// Gets whether the SSL handshake was successful.
/// </summary>
public bool IsSuccessful { get; set; }
/// <summary>
/// Gets the exception message if the handshake failed.
/// </summary>
public string? FailureReason { get; set; }
/// <summary>
/// Gets whether the failure is recoverable (can retry) or not.
/// </summary>
public bool IsRecoverableFailure { get; set; }
/// <summary>
/// Gets the remote endpoint that was connected to/from.
/// </summary>
public string? RemoteEndPoint { get; set; }
/// <summary>
/// Gets whether mutual authentication (client certificate) was required.
/// </summary>
public bool MutualAuthenticationRequired { get; set; }
/// <summary>
/// Returns a formatted string summary of the SSL handshake metrics.
/// </summary>
public override string ToString()
{
var result = $"SSL Metrics: Attempt {AttemptCount}, ";
result += IsSuccessful
? $"Success in {TotalDuration?.TotalMilliseconds:F2}ms, Protocol={SslProtocolVersion}, Cipher={CipherAlgorithm}"
: $"Failed - {FailureReason} (Recoverable: {IsRecoverableFailure})";
if (ServerAuthenticationDuration.HasValue)
{
result += $", ServerAuth={ServerAuthenticationDuration.Value.TotalMilliseconds:F2}ms";
}
if (ClientAuthenticationDuration.HasValue)
{
result += $", ClientAuth={ClientAuthenticationDuration.Value.TotalMilliseconds:F2}ms";
}
if (CumulativeDuration.HasValue && AttemptCount > 1)
{
result += $", TotalRetries={CumulativeDuration.Value.TotalMilliseconds:F2}ms";
}
return result;
}
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
using System.Text;
// 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.
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 TcpSeparators
{
public static byte[] NewLine => Encoding.UTF8.GetBytes("\n");
+216 -33
View File
@@ -1,13 +1,14 @@
using System.Diagnostics;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
// 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.
namespace EonaCat.Connections.Models
{
public class Configuration
// 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 : IDisposable
{
public event EventHandler<string> OnLog;
public List<string> TrustedThumbprints = new List<string>();
@@ -15,8 +16,27 @@ namespace EonaCat.Connections.Models
public int ReconnectDelayInSeconds { get; set; } = 5;
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 int SSLTimeoutInSeconds { get; set; } = 35;
public int SSLRetryDelayInSeconds { get; set; } = 5;
/// <summary>
/// Enables exponential backoff for SSL handshake retries.
/// When enabled, retry delays increase progressively: initial delay * (2 ^ attempt).
/// Caps out at SSLRetryDelayMaxSeconds to prevent excessive delays.
/// Default is false to provide instant retries after a fixed 5-second delay, allowing SSL handshakes up to 30 seconds to complete.
/// </summary>
public bool UseExponentialBackoffForSslRetries { get; set; } = false;
/// <summary>
/// Maximum delay in seconds for SSL handshake retries when exponential backoff is enabled. (default: 60)
/// </summary>
public int SSLRetryDelayMaxSeconds { get; set; } = 60;
/// <summary>
/// Enables diagnostic/tracing for SSL handshake process.
/// When enabled, detailed metrics and performance data are collected for each SSL connection attempt.
/// </summary>
public bool EnableSslDiagnostics { get; set; } = false;
public FramingMode MessageFraming { get; set; } = FramingMode.None;
public byte[] Delimiter { get; internal set; } = Helpers.TcpSeparators.Percent;
@@ -24,17 +44,28 @@ namespace EonaCat.Connections.Models
public const string PING_VALUE = "¯";
public const string PONG_VALUE = "‰";
public const string SSL_ERROR_PREFIX = "[SSL_ERROR]";
public const string SSL_ERROR_SUFFIX = "[/SSL_ERROR]";
public ProtocolType Protocol { get; set; } = ProtocolType.TCP;
public int Port { get; set; } = 8080;
public string Host { get; set; } = "127.0.0.1";
public bool UseSsl { get; set; } = false;
public bool UseSsl => Certificate != null;
public X509Certificate2 Certificate { get; set; }
public X509Certificate2Collection AdditionalCertificates { get; set; }
public bool UseAesEncryption { get; set; } = false;
public int BufferSize { get; set; } = (int)BufferSizeMaximum.Medium;
public int BufferSize { get; set; } = (int)BufferSizeMaximum.ExtraLarge;
public int MaxConnections { get; set; } = 100000;
public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30);
public bool EnableKeepAlive { get; set; } = true;
public int KeepAliveTimeSeconds { get; set; } = 60;
public int KeepAliveIntervalSeconds { get; set; } = 10;
/// <summary>
/// Number of unacknowledged TCP keep-alive probes before the connection is considered dead. (default: 10)
/// </summary>
public int KeepAliveRetryCount { get; set; } = 10;
public bool EnableNagle { get; set; } = false;
// For testing purposes, allow self-signed certificates
@@ -44,13 +75,89 @@ namespace EonaCat.Connections.Models
public bool CheckAgainstInternalTrustedCertificates { get; private set; } = true;
public bool CheckCertificateRevocation { get; set; }
public bool MutuallyAuthenticate { get; set; } = true;
public double ClientTimeoutInMinutes { get; set; } = 10;
public bool EnableHeartbeat { get; set; }
public bool AllowTlsRenegotiation { get; set; }
public bool UseBigEndian { get; set; }
internal int HeartbeatIntervalSeconds { get; set; } = 5;
public 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; }
public double IdleTimeoutSeconds { get; set; } = 30; // 0 means no idle timeout
/// <summary>
/// Determines whether to enable RST (Reset) flag on socket close.
/// </summary>
public bool EnableRST { get; set; }
/// <summary>
/// Gets or sets the total bytes to read as a message prefix (if framing mode is LengthPrefixed) is enabled.
/// </summary>
/// <remarks>The length prefix index determines where the length information is read or written in
/// the buffer. Changing this value may affect how data is parsed or serialized, depending on the protocol or
/// format in use. (default: 4)</remarks>
public int LengthPrefixedLength { get; set; } = 4;
public bool EnableConnectionDebugLogs { get; set; }
/// <summary>
/// Enables automatic periodic generation of HTML status reports for errors and network health.
/// When enabled, reports are written to <see cref="HtmlReportOutputDirectory"/> every <see cref="HtmlReportIntervalSeconds"/> seconds.
/// </summary>
public bool EnableAutoHtmlReports { get; set; }
/// <summary>
/// The directory where auto-generated HTML reports are written. Defaults to "reports" under the current directory.
/// </summary>
public string HtmlReportOutputDirectory { get; set; } = Path.Combine(Directory.GetCurrentDirectory(), "reports");
/// <summary>
/// The interval in seconds between automatic HTML report generations. (default: 60)
/// </summary>
public int HtmlReportIntervalSeconds { get; set; } = 60;
/// <summary>
/// Enables automatic periodic generation of an HTML status page showing connected clients and throughput.
/// When enabled, the page is written to <see cref="HtmlReportOutputDirectory"/> every <see cref="ServerStatusPageIntervalSeconds"/> seconds.
/// Default is off.
/// </summary>
public bool EnableServerStatusPage { get; set; }
/// <summary>
/// The interval in seconds between automatic server status page generations. (default: 5)
/// </summary>
public int ServerStatusPageIntervalSeconds { get; set; } = 5;
/// <summary>
/// Enables a lightweight REST API that exposes health and status information via HTTP.
/// When enabled, the API listens on <see cref="HealthApiPort"/> (use 0 for a random available port).
/// Default is off.
/// </summary>
public bool EnableHealthApi { get; set; }
/// <summary>
/// The port for the health REST API. Use 0 (default) for a random available port.
/// The actual port can be retrieved from <c>NetworkServer.HealthApi.Port</c> or <c>NetworkClient.HealthApi.Port</c> after starting.
/// </summary>
public int HealthApiPort { get; set; } = 0;
/// <summary>
/// The IP address the health API server binds to.
/// Use <c>IPAddress.Any</c> (0.0.0.0) for Docker/container environments where the API must be reachable from outside the container.
/// Defaults to <c>IPAddress.Loopback</c> (127.0.0.1) for security.
/// </summary>
public IPAddress HealthApiBindAddress { get; set; } = IPAddress.Loopback;
/// <summary>
/// Write timeout in seconds for network operations. If a write operation takes longer than this duration, it will be aborted and an exception will be thrown. (default: 120 seconds)
/// </summary>
public double WriteTimeoutSeconds { get; set; } = 120;
/// <summary>
/// Read timeout in seconds for network operations. If a read operation takes longer than this duration, it will be aborted and an exception will be thrown. (default: 300 seconds)
/// </summary>
public double ReadTimeoutSeconds { get; set; } = 300;
internal RemoteCertificateValidationCallback GetRemoteCertificateValidationCallback()
{
@@ -67,15 +174,27 @@ namespace EonaCat.Connections.Models
{
var stopwatch = Stopwatch.StartNew();
bool result = false;
string reason = "Unknown";
try
{
if (IsSelfSignedEnabled)
if (certificate == null)
{
OnLog?.Invoke(this, $"WARNING: Accepting all invalid certificates: {certificate?.Subject}");
return true;
reason = "Certificate is null";
return false;
}
if (chain != null)
if (IsSelfSignedEnabled)
{
reason = $"Accepting all certificates (IsSelfSignedEnabled): {certificate.Subject}";
result = true;
return result;
}
try
{
if (chain != null && certificate is X509Certificate2 cert)
{
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.VerificationFlags =
@@ -83,69 +202,133 @@ namespace EonaCat.Connections.Models
X509VerificationFlags.IgnoreEndRevocationUnknown |
X509VerificationFlags.AllowUnknownCertificateAuthority;
chain.Build((X509Certificate2)certificate);
chain.Build(cert);
foreach (var status in chain.ChainStatus)
{
OnLog?.Invoke(this, $"ChainStatus: {status.Status} - {status.StatusInformation}");
}
}
}
catch (Exception ex)
{
OnLog?.Invoke(this, $"Certificate validation succeeded in {stopwatch.ElapsedMilliseconds} ms");
}
if (sslPolicyErrors == SslPolicyErrors.None)
{
OnLog?.Invoke(this, $"Certificate validation succeeded in {stopwatch.ElapsedMilliseconds} ms");
return true;
reason = "Certificate validation succeeded";
result = true;
return result;
}
if (CheckAgainstInternalTrustedCertificates && certificate is X509Certificate2 cert2)
{
string thumbprint = cert2.Thumbprint?.Replace(" ", "").ToLowerInvariant();
if (thumbprint != null && TrustedThumbprints.Contains(thumbprint))
if (!string.IsNullOrEmpty(thumbprint) && TrustedThumbprints.Contains(thumbprint))
{
OnLog?.Invoke(this, $"Trusted thumbprint matched: {thumbprint}");
return true;
reason = $"Trusted thumbprint matched: {thumbprint}";
result = true;
return result;
}
OnLog?.Invoke(this, $"Certificate thumbprint {thumbprint} not trusted (Validation took {stopwatch.ElapsedMilliseconds} ms)");
return false;
reason = $"Certificate thumbprint not trusted: {thumbprint}";
result = false;
return result;
}
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors) && chain != null)
{
bool fatal = false;
foreach (var status in chain.ChainStatus)
{
if (status.Status == X509ChainStatusFlags.Revoked)
switch (status.Status)
{
OnLog?.Invoke(this, $"Certificate revoked: {status.StatusInformation}");
case X509ChainStatusFlags.Revoked:
case X509ChainStatusFlags.NotSignatureValid:
fatal = true;
reason = $"Fatal chain error: {status.Status} - {status.StatusInformation}";
break;
}
if (status.Status == X509ChainStatusFlags.NotSignatureValid)
if (fatal)
{
OnLog?.Invoke(this, $"Invalid signature: {status.StatusInformation}");
fatal = true;
break;
}
}
if (!fatal)
{
OnLog?.Invoke(this, $"Certificate accepted (ignoring minor chain warnings)");
return true;
reason = "Certificate accepted (ignoring non-fatal chain warnings)";
result = true;
return result;
}
OnLog?.Invoke(this, $"Certificate validation failed (Validation took {stopwatch.ElapsedMilliseconds} ms)");
return false;
result = false;
return result;
}
OnLog?.Invoke(this, $"Certificate rejected: {sslPolicyErrors} (Validation took {stopwatch.ElapsedMilliseconds} ms)");
return false;
reason = $"Certificate rejected: {sslPolicyErrors}";
result = false;
return result;
}
catch (Exception ex)
{
reason = $"Certificate validation exception: {ex}";
result = false;
return result;
}
finally
{
stopwatch.Stop();
OnLog?.Invoke(this,
$"Certificate validation result={result}. " +
$"Reason={reason}. " +
$"Duration={stopwatch.ElapsedMilliseconds} ms");
}
}
/// <summary>
/// Detects whether the application is running inside a Docker container.
/// </summary>
public static bool IsRunningInContainer =>
Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true" ||
File.Exists("/.dockerenv");
/// <summary>
/// Configures the settings for use in a Docker/container environment.
/// Sets the host to 0.0.0.0 and the health API bind address to <c>IPAddress.Any</c>.
/// </summary>
public Configuration UseContainerDefaults()
{
Host = "0.0.0.0";
HealthApiBindAddress = IPAddress.Any;
return this;
}
/// <summary>
/// Calculates the SSL retry delay for a given attempt number, accounting for exponential backoff if enabled.
/// </summary>
/// <param name="attemptNumber">The attempt number (1-based).</param>
/// <returns>The delay in seconds.</returns>
public int GetSslRetryDelaySeconds(int attemptNumber)
{
if (!UseExponentialBackoffForSslRetries || attemptNumber <= 1)
{
return SSLRetryDelayInSeconds;
}
// Exponential backoff: baseDelay * 2^(attempt-1), capped at max
var exponentialDelay = SSLRetryDelayInSeconds * Math.Pow(2, attemptNumber - 1);
return (int)Math.Min(exponentialDelay, SSLRetryDelayMaxSeconds);
}
public void Dispose()
{
Certificate?.Dispose();
}
}
}
+270 -128
View File
@@ -1,44 +1,32 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
// 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.
using System.Text;
using System.Threading.Channels;
namespace EonaCat.Connections.Models
{
public class Connection
// 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 : IDisposable, IAsyncDisposable
{
public string Id { get; set; }
public TcpClient TcpClient { get; set; }
public UdpClient UdpClient { get; set; }
public IPEndPoint RemoteEndPoint { get; set; }
public Stream Stream { get; set; }
public SemaphoreSlim WriteLock { get; } = new(1, 1);
internal Decoder Utf8Decoder { get; } = Encoding.UTF8.GetDecoder();
internal char[] CharBuffer { get; set; } = new char[8192];
private string _nickName;
public string Nickname
{
get
{
if (string.IsNullOrWhiteSpace(_nickName))
{
_nickName = Id;
}
return _nickName;
}
set
{
if (string.IsNullOrWhiteSpace(value))
{
_nickName = Id;
}
else
{
_nickName = value;
}
}
get => string.IsNullOrWhiteSpace(_nickName) ? Id : _nickName;
set => _nickName = string.IsNullOrWhiteSpace(value) ? Id : value;
}
public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id;
@@ -49,18 +37,29 @@ namespace EonaCat.Connections.Models
{
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
if (TcpClient?.Client == null)
{
return false;
}
var socket = TcpClient.Client;
if (!socket.Connected)
{
return false;
}
// Send a zero-byte packet to check if the connection is still alive
try
{
socket.Send(Array.Empty<byte>(), 0, 0);
LastActive = DateTime.UtcNow;
}
catch
{
return false;
}
return true;
}
catch
{
@@ -70,139 +69,98 @@ namespace EonaCat.Connections.Models
}
public DateTime ConnectedAt { get; internal set; }
public DateTime LastActive { get; internal set; }
private long _lastActiveTicks;
public DateTime LastActive
{
get => new DateTime(Interlocked.Read(ref _lastActiveTicks));
internal set => Interlocked.Exchange(ref _lastActiveTicks, value.Ticks);
}
public DateTime DisconnectionTime { get; internal set; }
public DateTime LastDataSent { get; internal set; }
public DateTime LastDataReceived { get; internal set; }
public int IdleTimeInSeconds()
public Channel<byte[]> SendQueue =
Channel.CreateBounded<byte[]>(new BoundedChannelOptions(8192)
{
var idleTime = IdleTime();
return (int)idleTime.TotalSeconds;
}
SingleReader = true,
SingleWriter = false,
//FullMode = BoundedChannelFullMode.DropOldest
});
public int IdleTimeInMinutes()
{
var idleTime = IdleTime();
return (int)idleTime.TotalMinutes;
}
public TimeSpan IdleTime() => DateTime.UtcNow - LastActive;
public int IdleTimeInHours()
{
var idleTime = IdleTime();
return (int)idleTime.TotalHours;
}
public int IdleTimeInSeconds() => (int)IdleTime().TotalSeconds;
public int IdleTimeInDays()
{
var idleTime = IdleTime();
return (int)idleTime.TotalDays;
}
public int IdleTimeInMinutes() => (int)IdleTime().TotalMinutes;
public TimeSpan IdleTime()
{
return DateTime.UtcNow - LastActive;
}
public int IdleTimeInHours() => (int)IdleTime().TotalHours;
public string IdleTimeFormatted(bool includeDays = true, bool includeHours = true, bool includeMinutes = true, bool includeSeconds = true, bool includeMilliseconds = true)
public int IdleTimeInDays() => (int)IdleTime().TotalDays;
public TimeSpan ConnectedTime() => DateTime.UtcNow - ConnectedAt;
public int ConnectedTimeInSeconds() => (int)ConnectedTime().TotalSeconds;
public int ConnectedTimeInMinutes() => (int)ConnectedTime().TotalMinutes;
public int ConnectedTimeInHours() => (int)ConnectedTime().TotalHours;
public int ConnectedTimeInDays() => (int)ConnectedTime().TotalDays;
public string FormatTime(TimeSpan span,
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");
parts.Add($"{(int)span.TotalDays:D2}d");
}
if (includeHours)
{
parts.Add($"{idleTime.Hours:D2}h");
parts.Add($"{span.Hours:D2}h");
}
if (includeMinutes)
{
parts.Add($"{idleTime.Minutes:D2}m");
parts.Add($"{span.Minutes:D2}m");
}
if (includeSeconds)
{
parts.Add($"{idleTime.Seconds:D2}s");
parts.Add($"{span.Seconds:D2}s");
}
if (includeMilliseconds)
{
parts.Add($"{idleTime.Milliseconds:D3}ms");
parts.Add($"{span.Milliseconds:D3}ms");
}
return string.Join(" ", parts);
}
public int ConnectedTimeInSeconds()
{
var connectedTime = DateTime.UtcNow - ConnectedAt;
return (int)connectedTime.TotalSeconds;
}
public string IdleTimeFormatted(bool days = true, bool hours = true, bool minutes = true, bool seconds = true, bool ms = true)
=> FormatTime(IdleTime(), days, hours, minutes, seconds, ms);
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 string ConnectedTimeFormatted(bool days = true, bool hours = true, bool minutes = true, bool seconds = true, bool ms = true)
=> FormatTime(ConnectedTime(), days, hours, minutes, seconds, ms);
public bool IsSecure { get; set; }
public bool IsEncrypted { get; set; }
public Aes AesEncryption { get; set; }
public CancellationTokenSource CancellationToken { get; set; }
private long _bytesReceived;
private long _bytesSent;
public long BytesReceived => Interlocked.Read(ref _bytesReceived);
public long BytesSent => Interlocked.Read(ref _bytesSent);
@@ -210,14 +168,198 @@ namespace EonaCat.Connections.Models
public void AddBytesSent(long count) => Interlocked.Add(ref _bytesSent, count);
public SemaphoreSlim SendLock { get; } = new SemaphoreSlim(1, 1);
public SemaphoreSlim ReadLock { get; } = new SemaphoreSlim(1, 1);
internal Task ReceiveTask { get; set; }
private int _disconnected;
public bool MarkDisconnected() => Interlocked.Exchange(ref _disconnected, 1) == 0;
public Dictionary<string, object> Metadata { get; } = new Dictionary<string, object>();
public Dictionary<string, object> Metadata { get; } = new();
public Task? SendLoopTask;
public Task ReceiveDataTask { get; internal set; }
public bool IsIdleTimeoutTriggered { get; internal set; }
public DateTime LastIdleLogUtc { get; internal set; }
public double ShowIdleReminderInSeconds { get; set; } = 20;
public bool IsClosing { get; internal set; }
private bool _disposed;
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
IsClosing = true;
DisconnectionTime = DateTime.UtcNow;
try
{
CancellationToken?.Cancel();
}
catch
{
// Do nothing
}
SendQueue.Writer.TryComplete();
if (SendLoopTask != null)
{
await SafeAwait(SendLoopTask);
}
if (ReceiveDataTask != null)
{
await SafeAwait(ReceiveDataTask);
}
DisposeManaged();
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
IsClosing = true;
DisconnectionTime = DateTime.UtcNow;
try
{
CancellationToken?.Cancel();
}
catch
{
// Do nothing
}
SendQueue.Writer.TryComplete();
DisposeManaged();
GC.SuppressFinalize(this);
}
private void DisposeManaged()
{
try
{
Stream?.Dispose();
}
catch
{
// Do nothing
}
try
{
ForceCloseTcpClient(TcpClient);
}
catch
{
// Do nothing
}
try
{
UdpClient?.Close();
UdpClient?.Dispose();
}
catch
{
// Do nothing
}
try
{
AesEncryption?.Dispose();
}
catch
{
// Do nothing
}
try
{
CancellationToken?.Dispose();
}
catch
{
// Do nothing
}
try
{
WriteLock?.Dispose();
}
catch
{
// Do nothing
}
Metadata.Clear();
Stream = null;
TcpClient = null;
UdpClient = null;
AesEncryption = null;
CancellationToken = null;
SendLoopTask = null;
ReceiveDataTask = null;
CharBuffer = null;
}
private static void ForceCloseTcpClient(TcpClient tcpClient)
{
if (tcpClient == null)
{
return;
}
try
{
tcpClient.Client?.Shutdown(SocketShutdown.Both);
}
catch
{
// Do nothing
}
try
{
tcpClient.Close();
}
catch
{
// Do nothing
}
try
{
tcpClient.Dispose();
}
catch
{
// Do nothing
}
}
private static async Task SafeAwait(Task task)
{
try
{
await task.ConfigureAwait(false);
}
catch
{
// Do nothing
}
}
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
// 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.
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 enum FramingMode
{
None,
+13 -7
View File
@@ -1,16 +1,22 @@
// 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.
namespace EonaCat.Connections.Models
namespace EonaCat.Connections.Models
{
public class ProcessedMessage<TData>
// 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 ProcessedMessage<TData> : IDisposable
{
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;
public void Dispose()
{
if (Data is IDisposable disposable)
{
disposable.Dispose();
}
}
}
}
@@ -1,12 +1,17 @@
// 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.
namespace EonaCat.Connections.Models
namespace EonaCat.Connections.Models
{
public class ProcessedTextMessage
// 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 ProcessedTextMessage : IDisposable
{
public string Text { get; set; }
public string ClientName { get; set; }
public string? ClientEndpoint { get; set; }
public void Dispose()
{
// Nothing to dispose
}
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
// 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.
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 enum ProtocolType
{
TCP,
@@ -0,0 +1,359 @@
using System.Collections.Concurrent;
using System.Text;
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 ServerStatusPage : IDisposable
{
private CancellationTokenSource _autoReportCts;
private Task _autoReportTask;
private volatile bool _autoReportRunning;
private readonly Func<ConcurrentDictionary<string, Connection>> _getClients;
private readonly Func<Stats> _getStats;
private readonly Configuration _config;
private readonly SocketStatusPage _errorPage;
public ServerStatusPage(
Func<ConcurrentDictionary<string, Connection>> getClients,
Func<Stats> getStats,
Configuration config,
SocketStatusPage errorPage = null)
{
_getClients = getClients;
_getStats = getStats;
_config = config;
_errorPage = errorPage;
}
/// <summary>
/// Gets whether automatic HTML status page generation is currently running.
/// </summary>
public bool IsAutoReportRunning => _autoReportRunning;
/// <summary>
/// Starts periodic automatic generation of the HTML server status page.
/// </summary>
/// <param name="outputDirectory">Directory where the HTML file is written.</param>
/// <param name="intervalSeconds">Interval in seconds between page generations.</param>
/// <param name="fileName">The HTML file name.</param>
public void StartAutoReport(string outputDirectory, int intervalSeconds = 5, string fileName = "status-server.html")
{
if (_autoReportRunning)
{
return;
}
_autoReportCts?.Dispose();
_autoReportCts = new CancellationTokenSource();
_autoReportRunning = true;
var interval = TimeSpan.FromSeconds(Math.Max(1, intervalSeconds));
var token = _autoReportCts.Token;
_autoReportTask = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
try
{
if (!Directory.Exists(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
var filePath = Path.Combine(outputDirectory, fileName);
var html = GenerateHtml();
File.WriteAllText(filePath, html, Encoding.UTF8);
}
catch
{
// Swallow to keep the loop alive
}
try
{
await Task.Delay(interval, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}, token);
}
/// <summary>
/// Stops the automatic HTML status page generation.
/// </summary>
public void StopAutoReport()
{
if (!_autoReportRunning)
{
return;
}
_autoReportRunning = false;
_autoReportCts?.Cancel();
try
{
_autoReportTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
}
}
public string GenerateHtml(string title = "Server Status")
{
var stats = _getStats();
var clients = _getClients();
var now = DateTime.UtcNow;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<meta http-equiv=\"refresh\" content=\"{_config.ServerStatusPageIntervalSeconds}\">");
sb.AppendLine($"<title>{HtmlEncode(title)}</title>");
sb.AppendLine("<style>");
sb.AppendLine("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; color: #333; }");
sb.AppendLine("h1 { color: #1a1a2e; }");
sb.AppendLine("h2 { color: #16213e; margin-top: 30px; }");
sb.AppendLine(".container { max-width: 1400px; margin: 0 auto; }");
sb.AppendLine(".summary-cards { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }");
sb.AppendLine(".card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 160px; }");
sb.AppendLine(".card .label { font-size: 0.85em; color: #666; text-transform: uppercase; }");
sb.AppendLine(".card .value { font-size: 1.8em; font-weight: bold; margin-top: 4px; }");
sb.AppendLine(".card.ok .value { color: #27ae60; }");
sb.AppendLine(".card.warn .value { color: #f39c12; }");
sb.AppendLine(".card.info .value { color: #2980b9; }");
sb.AppendLine("table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
sb.AppendLine("th { background: #1a1a2e; color: #fff; padding: 12px 16px; text-align: left; font-size: 0.85em; text-transform: uppercase; }");
sb.AppendLine("td { padding: 10px 16px; border-bottom: 1px solid #eee; font-size: 0.9em; }");
sb.AppendLine("tr:hover td { background: #f0f4ff; }");
sb.AppendLine(".badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: bold; }");
sb.AppendLine(".badge-secure { background: #e8f5e9; color: #27ae60; }");
sb.AppendLine(".badge-plain { background: #fff3e0; color: #f39c12; }");
sb.AppendLine(".badge-encrypted { background: #e3f2fd; color: #2980b9; }");
sb.AppendLine(".no-clients { text-align: center; padding: 60px 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
sb.AppendLine(".no-clients .icon { font-size: 3em; }");
sb.AppendLine(".no-clients p { color: #888; font-size: 1.2em; }");
sb.AppendLine(".footer { margin-top: 30px; text-align: center; color: #999; font-size: 0.85em; }");
sb.AppendLine(".badge-ssl-error { background: #f3e8fd; color: #8e44ad; }");
sb.AppendLine(".ssl-table { margin-top: 16px; }");
sb.AppendLine("tr.ssl-row td { border-left: 3px solid #8e44ad; }");
sb.AppendLine(".no-ssl-errors { text-align: center; padding: 30px 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
sb.AppendLine(".no-ssl-errors p { color: #27ae60; font-size: 1em; }");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<div class=\"container\">");
sb.AppendLine($"<h1>{HtmlEncode(title)}</h1>");
// Server summary cards
sb.AppendLine("<div class=\"summary-cards\">");
sb.AppendLine($"<div class=\"card info\"><div class=\"label\">Listen Address</div><div class=\"value\" style=\"font-size:1em\">{HtmlEncode(_config.Host)}:{_config.Port}</div></div>");
sb.AppendLine($"<div class=\"card info\"><div class=\"label\">Protocol</div><div class=\"value\" style=\"font-size:1em\">{HtmlEncode(_config.Protocol.ToString())}</div></div>");
sb.AppendLine($"<div class=\"card ok\"><div class=\"label\">Connected Clients</div><div class=\"value\">{stats.ActiveConnections}</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Max Connections</div><div class=\"value\">{_config.MaxConnections}</div></div>");
sb.AppendLine($"<div class=\"card info\"><div class=\"label\">Total Connections</div><div class=\"value\">{stats.TotalConnections}</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Dropped Connections</div><div class=\"value\">{stats.DroppedConnections}</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Dropped Packets</div><div class=\"value\">{stats.DroppedPackets}</div></div>");
sb.AppendLine($"<div class=\"card info\"><div class=\"label\">Uptime</div><div class=\"value\" style=\"font-size:1em\">{FormatTimeSpan(stats.Uptime)}</div></div>");
sb.AppendLine("</div>");
// Throughput cards
sb.AppendLine("<div class=\"summary-cards\">");
sb.AppendLine($"<div class=\"card info\"><div class=\"label\">Total Bytes Sent</div><div class=\"value\" style=\"font-size:1em\">{FormatBytes(stats.BytesSent)}</div></div>");
sb.AppendLine($"<div class=\"card info\"><div class=\"label\">Total Bytes Received</div><div class=\"value\" style=\"font-size:1em\">{FormatBytes(stats.BytesReceived)}</div></div>");
sb.AppendLine($"<div class=\"card info\"><div class=\"label\">Messages Sent</div><div class=\"value\">{stats.MessagesSent}</div></div>");
sb.AppendLine($"<div class=\"card info\"><div class=\"label\">Messages Received</div><div class=\"value\">{stats.MessagesReceived}</div></div>");
sb.AppendLine($"<div class=\"card info\"><div class=\"label\">Msg/sec</div><div class=\"value\">{stats.MessagesPerSecond:F2}</div></div>");
sb.AppendLine("</div>");
// Connected clients table
sb.AppendLine("<h2>Connected Clients</h2>");
var clientList = clients.Values.ToArray();
if (clientList.Length == 0)
{
sb.AppendLine("<div class=\"no-clients\">");
sb.AppendLine("<div class=\"icon\">&#128274;</div>");
sb.AppendLine("<p>No clients connected.</p>");
sb.AppendLine("</div>");
}
else
{
sb.AppendLine("<table>");
sb.AppendLine("<thead><tr>");
sb.AppendLine("<th>Nickname</th>");
sb.AppendLine("<th>Client ID</th>");
sb.AppendLine("<th>Remote Endpoint</th>");
sb.AppendLine("<th>Connected Since</th>");
sb.AppendLine("<th>Connected Time</th>");
sb.AppendLine("<th>Idle Time</th>");
sb.AppendLine("<th>Bytes Sent</th>");
sb.AppendLine("<th>Bytes Received</th>");
sb.AppendLine("<th>Last Data Sent</th>");
sb.AppendLine("<th>Last Data Received</th>");
sb.AppendLine("<th>Security</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
foreach (var client in clientList)
{
var securityBadge = client.IsEncrypted
? "<span class=\"badge badge-encrypted\">AES Encrypted</span>"
: client.IsSecure
? "<span class=\"badge badge-secure\">SSL/TLS</span>"
: "<span class=\"badge badge-plain\">Plain</span>";
sb.AppendLine("<tr>");
sb.AppendLine($"<td><strong>{HtmlEncode(client.Nickname)}</strong></td>");
sb.AppendLine($"<td>{HtmlEncode(client.Id ?? "-")}</td>");
sb.AppendLine($"<td>{HtmlEncode(client.RemoteEndPoint?.ToString() ?? "-")}</td>");
sb.AppendLine($"<td>{client.ConnectedAt:yyyy-MM-dd HH:mm:ss} UTC</td>");
sb.AppendLine($"<td>{FormatTimeSpan(client.ConnectedTime())}</td>");
sb.AppendLine($"<td>{FormatTimeSpan(client.IdleTime())}</td>");
sb.AppendLine($"<td>{FormatBytes(client.BytesSent)}</td>");
sb.AppendLine($"<td>{FormatBytes(client.BytesReceived)}</td>");
sb.AppendLine($"<td>{FormatDateTime(client.LastDataSent)}</td>");
sb.AppendLine($"<td>{FormatDateTime(client.LastDataReceived)}</td>");
sb.AppendLine($"<td>{securityBadge}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
}
// SSL Errors section
if (_errorPage != null)
{
var sslErrors = _errorPage.GetSslErrors();
sb.AppendLine("<h2>SSL Errors</h2>");
if (sslErrors.Count == 0)
{
sb.AppendLine("<div class=\"no-ssl-errors\">");
sb.AppendLine("<p>&#10004; No SSL errors recorded.</p>");
sb.AppendLine("</div>");
}
else
{
sb.AppendLine("<div class=\"summary-cards\">");
sb.AppendLine($"<div class=\"card error\"><div class=\"label\">Total SSL Errors</div><div class=\"value\">{sslErrors.Count}</div></div>");
sb.AppendLine("</div>");
sb.AppendLine("<table class=\"ssl-table\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine("<th>Timestamp</th>");
sb.AppendLine("<th>Client / Server</th>");
sb.AppendLine("<th>Client ID</th>");
sb.AppendLine("<th>Error</th>");
sb.AppendLine("<th>Exception Type</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
foreach (var sslError in sslErrors)
{
sb.AppendLine("<tr class=\"ssl-row\">");
sb.AppendLine($"<td>{sslError.Timestamp:yyyy-MM-dd HH:mm:ss} UTC</td>");
sb.AppendLine($"<td><span class=\"badge badge-ssl-error\">{HtmlEncode(sslError.Nickname ?? sslError.ClientId ?? "-")}</span></td>");
sb.AppendLine($"<td>{HtmlEncode(sslError.ClientId ?? "-")}</td>");
sb.AppendLine($"<td>{HtmlEncode(sslError.Message ?? "-")}</td>");
sb.AppendLine($"<td>{HtmlEncode(sslError.ExceptionType ?? "-")}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
}
}
sb.AppendLine($"<div class=\"footer\">Generated at {now:yyyy-MM-dd HH:mm:ss} UTC &mdash; Auto-refresh every {_config.ServerStatusPageIntervalSeconds}s &mdash; EonaCat.Connections</div>");
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
private static string FormatBytes(long bytes)
{
if (bytes < 1024)
{
return $"{bytes} B";
}
if (bytes < 1024 * 1024)
{
return $"{bytes / 1024.0:F1} KB";
}
if (bytes < 1024 * 1024 * 1024)
{
return $"{bytes / (1024.0 * 1024.0):F2} MB";
}
return $"{bytes / (1024.0 * 1024.0 * 1024.0):F2} GB";
}
private static string FormatTimeSpan(TimeSpan span)
{
if (span.TotalDays >= 1)
{
return $"{(int)span.TotalDays}d {span.Hours:D2}h {span.Minutes:D2}m {span.Seconds:D2}s";
}
if (span.TotalHours >= 1)
{
return $"{span.Hours:D2}h {span.Minutes:D2}m {span.Seconds:D2}s";
}
if (span.TotalMinutes >= 1)
{
return $"{span.Minutes:D2}m {span.Seconds:D2}s";
}
return $"{span.Seconds}s";
}
private static string FormatDateTime(DateTime dt)
{
if (dt == default)
{
return "-";
}
return $"{dt:yyyy-MM-dd HH:mm:ss} UTC";
}
private static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
public void Dispose()
{
StopAutoReport();
_autoReportCts?.Dispose();
}
}
}
@@ -0,0 +1,32 @@
using System.Net.Sockets;
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 SocketErrorEntry
{
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string Source { get; set; }
public string ClientId { get; set; }
public string Nickname { get; set; }
public SocketError? SocketErrorCode { get; set; }
public string ErrorCode { get; set; }
public string Message { get; set; }
public string ExceptionType { get; set; }
public string StackTrace { get; set; }
public Exception Exception { get; set; }
public bool IsSslError { get; set; }
public bool IsSocketException => SocketErrorCode.HasValue;
public override string ToString()
{
var code = SocketErrorCode.HasValue ? $" [{SocketErrorCode}]" : string.Empty;
var client = !string.IsNullOrEmpty(ClientId) ? $" Client={ClientId}" : string.Empty;
var nick = !string.IsNullOrEmpty(Nickname) ? $" ({Nickname})" : string.Empty;
return $"[{Timestamp:O}] [{Source}]{code}{client}{nick} {Message}";
}
}
}
@@ -0,0 +1,521 @@
using System.Collections.Concurrent;
using System.Net.Sockets;
using System.Text;
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 SocketStatusPage : IDisposable
{
private readonly ConcurrentQueue<SocketErrorEntry> _errors = new ConcurrentQueue<SocketErrorEntry>();
private readonly int _maxEntries;
private CancellationTokenSource _autoReportCts;
private Task _autoReportTask;
private volatile bool _autoReportRunning;
public SocketStatusPage(int maxEntries = 1000)
{
_maxEntries = maxEntries;
}
/// <summary>
/// Starts periodic automatic generation of the HTML status page.
/// </summary>
/// <param name="outputDirectory">Directory where the HTML file is written.</param>
/// <param name="intervalSeconds">Interval in seconds between report generations.</param>
/// <param name="fileName">The HTML file name.</param>
public void StartAutoHtmlReport(string outputDirectory, int intervalSeconds = 60, string fileName = "status-errors.html")
{
if (_autoReportRunning)
{
return;
}
_autoReportCts?.Dispose();
_autoReportCts = new CancellationTokenSource();
_autoReportRunning = true;
var interval = TimeSpan.FromSeconds(Math.Max(5, intervalSeconds));
var token = _autoReportCts.Token;
_autoReportTask = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
try
{
if (!Directory.Exists(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
var filePath = Path.Combine(outputDirectory, fileName);
SaveHtmlStatusPage(filePath);
}
catch
{
// Swallow to keep the loop alive
}
try
{
await Task.Delay(interval, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}, token);
}
/// <summary>
/// Stops the automatic HTML report generation.
/// </summary>
public void StopAutoHtmlReport()
{
if (!_autoReportRunning)
{
return;
}
_autoReportRunning = false;
_autoReportCts?.Cancel();
try
{
_autoReportTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
}
}
/// <summary>
/// Gets whether automatic HTML report generation is currently running.
/// </summary>
public bool IsAutoHtmlReportRunning => _autoReportRunning;
public void AddError(SocketErrorEntry entry)
{
if (entry == null)
{
return;
}
_errors.Enqueue(entry);
while (_errors.Count > _maxEntries)
{
_errors.TryDequeue(out _);
}
}
public IReadOnlyList<SocketErrorEntry> GetAllErrors()
{
return _errors.ToArray();
}
public IReadOnlyList<SocketErrorEntry> GetRecentErrors(int count)
{
var all = _errors.ToArray();
int skip = Math.Max(0, all.Length - count);
var result = new SocketErrorEntry[Math.Min(count, all.Length)];
Array.Copy(all, skip, result, 0, result.Length);
return result;
}
public IReadOnlyList<SocketErrorEntry> GetErrorsSince(DateTime sinceUtc)
{
var result = new List<SocketErrorEntry>();
foreach (var entry in _errors)
{
if (entry.Timestamp >= sinceUtc)
{
result.Add(entry);
}
}
return result;
}
public IReadOnlyList<SocketErrorEntry> GetErrorsBySource(string source)
{
var result = new List<SocketErrorEntry>();
foreach (var entry in _errors)
{
if (string.Equals(entry.Source, source, StringComparison.OrdinalIgnoreCase))
{
result.Add(entry);
}
}
return result;
}
public IReadOnlyList<SocketErrorEntry> GetSocketExceptions()
{
var result = new List<SocketErrorEntry>();
foreach (var entry in _errors)
{
if (entry.IsSocketException)
{
result.Add(entry);
}
}
return result;
}
public IReadOnlyList<SocketErrorEntry> GetErrorsBySocketErrorCode(SocketError errorCode)
{
var result = new List<SocketErrorEntry>();
foreach (var entry in _errors)
{
if (entry.SocketErrorCode == errorCode)
{
result.Add(entry);
}
}
return result;
}
public int TotalErrors => _errors.Count;
public int SocketExceptionCount
{
get
{
int count = 0;
foreach (var entry in _errors)
{
if (entry.IsSocketException)
{
count++;
}
}
return count;
}
}
public int GeneralExceptionCount
{
get
{
int count = 0;
foreach (var entry in _errors)
{
if (!entry.IsSocketException)
{
count++;
}
}
return count;
}
}
public int SslErrorCount
{
get
{
int count = 0;
foreach (var entry in _errors)
{
if (entry.IsSslError)
{
count++;
}
}
return count;
}
}
public IReadOnlyList<SocketErrorEntry> GetSslErrors()
{
var result = new List<SocketErrorEntry>();
foreach (var entry in _errors)
{
if (entry.IsSslError)
{
result.Add(entry);
}
}
return result;
}
public SocketErrorEntry LastError
{
get
{
var all = _errors.ToArray();
return all.Length > 0 ? all[all.Length - 1] : null;
}
}
public void Clear()
{
while (_errors.TryDequeue(out _)) { }
}
public string GetSummary()
{
var errors = _errors.ToArray();
if (errors.Length == 0)
{
return "No errors recorded.";
}
var sb = new StringBuilder();
sb.AppendLine($"=== Socket Status Page ===");
sb.AppendLine($"Total Errors: {errors.Length}");
sb.AppendLine($"Socket Exceptions: {errors.Count(e => e.IsSocketException)}");
sb.AppendLine($"Other Exceptions: {errors.Count(e => !e.IsSocketException)}");
sb.AppendLine($"First Error: {errors[0].Timestamp:O}");
sb.AppendLine($"Last Error: {errors[errors.Length - 1].Timestamp:O}");
var bySource = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var byCode = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in errors)
{
var source = entry.Source ?? "Unknown";
if (bySource.ContainsKey(source))
{
bySource[source]++;
}
else
{
bySource[source] = 1;
}
var code = entry.ErrorCode ?? "N/A";
if (byCode.ContainsKey(code))
{
byCode[code]++;
}
else
{
byCode[code] = 1;
}
}
sb.AppendLine();
sb.AppendLine("By Source:");
foreach (var kvp in bySource)
{
sb.AppendLine($" {kvp.Key}: {kvp.Value}");
}
sb.AppendLine();
sb.AppendLine("By Error Code:");
foreach (var kvp in byCode.OrderByDescending(x => x.Value))
{
sb.AppendLine($" {kvp.Key}: {kvp.Value}");
}
return sb.ToString();
}
public string GenerateHtmlStatusPage(string title = "Socket Status Page")
{
var errors = _errors.ToArray();
var now = DateTime.UtcNow;
var bySource = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var byCode = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in errors)
{
var source = entry.Source ?? "Unknown";
if (bySource.ContainsKey(source))
{
bySource[source]++;
}
else
{
bySource[source] = 1;
}
var code = entry.ErrorCode ?? "N/A";
if (byCode.ContainsKey(code))
{
byCode[code]++;
}
else
{
byCode[code] = 1;
}
}
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{HtmlEncode(title)}</title>");
sb.AppendLine("<style>");
sb.AppendLine("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; color: #333; }");
sb.AppendLine("h1 { color: #1a1a2e; }");
sb.AppendLine("h2 { color: #16213e; margin-top: 30px; }");
sb.AppendLine(".container { max-width: 1200px; margin: 0 auto; }");
sb.AppendLine(".summary-cards { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }");
sb.AppendLine(".card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 180px; }");
sb.AppendLine(".card .label { font-size: 0.85em; color: #666; text-transform: uppercase; }");
sb.AppendLine(".card .value { font-size: 1.8em; font-weight: bold; margin-top: 4px; }");
sb.AppendLine(".card.ok .value { color: #27ae60; }");
sb.AppendLine(".card.warn .value { color: #f39c12; }");
sb.AppendLine(".card.error .value { color: #e74c3c; }");
sb.AppendLine(".breakdown { display: flex; gap: 24px; flex-wrap: wrap; margin-bottom: 24px; }");
sb.AppendLine(".breakdown-section { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); flex: 1; min-width: 250px; }");
sb.AppendLine(".breakdown-section h3 { margin-top: 0; color: #16213e; }");
sb.AppendLine(".breakdown-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #eee; }");
sb.AppendLine(".breakdown-item:last-child { border-bottom: none; }");
sb.AppendLine("table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
sb.AppendLine("th { background: #1a1a2e; color: #fff; padding: 12px 16px; text-align: left; font-size: 0.85em; text-transform: uppercase; }");
sb.AppendLine("td { padding: 10px 16px; border-bottom: 1px solid #eee; font-size: 0.9em; }");
sb.AppendLine("tr:hover td { background: #f0f4ff; }");
sb.AppendLine("tr.socket-error td { border-left: 3px solid #e74c3c; }");
sb.AppendLine("tr.general-error td { border-left: 3px solid #f39c12; }");
sb.AppendLine(".timestamp { color: #888; font-size: 0.85em; }");
sb.AppendLine(".badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: bold; }");
sb.AppendLine(".badge-socket { background: #fde8e8; color: #e74c3c; }");
sb.AppendLine(".badge-general { background: #fef3e2; color: #f39c12; }");
sb.AppendLine(".badge-ssl { background: #f3e8fd; color: #8e44ad; }");
sb.AppendLine("tr.ssl-error td { border-left: 3px solid #8e44ad; }");
sb.AppendLine(".no-errors { text-align: center; padding: 60px 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
sb.AppendLine(".no-errors .icon { font-size: 3em; }");
sb.AppendLine(".no-errors p { color: #27ae60; font-size: 1.2em; }");
sb.AppendLine(".footer { margin-top: 30px; text-align: center; color: #999; font-size: 0.85em; }");
sb.AppendLine(".stack-trace { font-family: 'Courier New', monospace; font-size: 0.8em; white-space: pre-wrap; word-break: break-all; max-height: 120px; overflow-y: auto; background: #f8f8f8; padding: 8px; border-radius: 4px; margin-top: 4px; }");
sb.AppendLine("details summary { cursor: pointer; color: #3498db; font-size: 0.85em; }");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<div class=\"container\">");
sb.AppendLine($"<h1>{HtmlEncode(title)}</h1>");
var statusClass = errors.Length == 0 ? "ok" : errors.Length < 10 ? "warn" : "error";
var socketCount = errors.Count(e => e.IsSocketException);
var generalCount = errors.Count(e => !e.IsSocketException && !e.IsSslError);
var sslCount = errors.Count(e => e.IsSslError);
sb.AppendLine("<div class=\"summary-cards\">");
sb.AppendLine($"<div class=\"card {statusClass}\"><div class=\"label\">Total Errors</div><div class=\"value\">{errors.Length}</div></div>");
sb.AppendLine($"<div class=\"card {(socketCount > 0 ? "error" : "ok")}\"><div class=\"label\">Socket Exceptions</div><div class=\"value\">{socketCount}</div></div>");
sb.AppendLine($"<div class=\"card {(sslCount > 0 ? "error" : "ok")}\"><div class=\"label\">SSL Errors</div><div class=\"value\">{sslCount}</div></div>");
sb.AppendLine($"<div class=\"card {(generalCount > 0 ? "warn" : "ok")}\"><div class=\"label\">General Exceptions</div><div class=\"value\">{generalCount}</div></div>");
if (errors.Length > 0)
{
sb.AppendLine($"<div class=\"card\"><div class=\"label\">First Error</div><div class=\"value\" style=\"font-size:1em\">{errors[0].Timestamp:yyyy-MM-dd HH:mm:ss} UTC</div></div>");
sb.AppendLine($"<div class=\"card\"><div class=\"label\">Last Error</div><div class=\"value\" style=\"font-size:1em\">{errors[errors.Length - 1].Timestamp:yyyy-MM-dd HH:mm:ss} UTC</div></div>");
}
sb.AppendLine("</div>");
if (errors.Length == 0)
{
sb.AppendLine("<div class=\"no-errors\">");
sb.AppendLine("<div class=\"icon\">&#10004;</div>");
sb.AppendLine("<p>No errors recorded. All systems operational.</p>");
sb.AppendLine("</div>");
}
else
{
sb.AppendLine("<div class=\"breakdown\">");
sb.AppendLine("<div class=\"breakdown-section\">");
sb.AppendLine("<h3>By Source</h3>");
foreach (var kvp in bySource)
{
sb.AppendLine($"<div class=\"breakdown-item\"><span>{HtmlEncode(kvp.Key)}</span><span><strong>{kvp.Value}</strong></span></div>");
}
sb.AppendLine("</div>");
sb.AppendLine("<div class=\"breakdown-section\">");
sb.AppendLine("<h3>By Error Code</h3>");
foreach (var kvp in byCode.OrderByDescending(x => x.Value))
{
sb.AppendLine($"<div class=\"breakdown-item\"><span>{HtmlEncode(kvp.Key)}</span><span><strong>{kvp.Value}</strong></span></div>");
}
sb.AppendLine("</div>");
sb.AppendLine("</div>");
sb.AppendLine("<h2>Error Log</h2>");
sb.AppendLine("<table>");
sb.AppendLine("<thead><tr><th>Timestamp</th><th>Source</th><th>Type</th><th>Client</th><th>Error Code</th><th>Message</th><th>Details</th></tr></thead>");
sb.AppendLine("<tbody>");
for (int i = errors.Length - 1; i >= 0; i--)
{
var e = errors[i];
var rowClass = e.IsSslError ? "ssl-error" : e.IsSocketException ? "socket-error" : "general-error";
var badge = e.IsSslError
? "<span class=\"badge badge-ssl\">SSL</span>"
: e.IsSocketException
? "<span class=\"badge badge-socket\">Socket</span>"
: "<span class=\"badge badge-general\">General</span>";
sb.AppendLine($"<tr class=\"{rowClass}\">");
sb.AppendLine($"<td class=\"timestamp\">{e.Timestamp:yyyy-MM-dd HH:mm:ss}</td>");
sb.AppendLine($"<td>{HtmlEncode(e.Source ?? "Unknown")}</td>");
sb.AppendLine($"<td>{badge}</td>");
sb.AppendLine($"<td>{HtmlEncode(e.Nickname ?? e.ClientId ?? "-")}</td>");
sb.AppendLine($"<td>{HtmlEncode(e.ErrorCode ?? "-")}</td>");
sb.AppendLine($"<td>{HtmlEncode(e.Message ?? "-")}</td>");
sb.AppendLine("<td>");
if (!string.IsNullOrEmpty(e.StackTrace))
{
sb.AppendLine($"<details><summary>Stack Trace</summary><div class=\"stack-trace\">{HtmlEncode(e.StackTrace)}</div></details>");
}
else
{
sb.AppendLine("-");
}
sb.AppendLine("</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
}
sb.AppendLine($"<div class=\"footer\">Generated at {now:yyyy-MM-dd HH:mm:ss} UTC &mdash; EonaCat.Connections</div>");
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
public void SaveHtmlStatusPage(string filePath, string title = "Socket Status Page")
{
var html = GenerateHtmlStatusPage(title);
File.WriteAllText(filePath, html, Encoding.UTF8);
}
private static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
public void Dispose()
{
StopAutoHtmlReport();
_autoReportCts?.Dispose();
}
}
}
+75 -9
View File
@@ -1,18 +1,84 @@
// 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.
using System.Threading;
namespace EonaCat.Connections
{
public class Stats
// 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 : IDisposable
{
public int ActiveConnections { get; set; }
public long TotalConnections { get; set; }
public long BytesSent { get; set; }
public long BytesReceived { get; set; }
public long MessagesSent { get; set; }
public long MessagesReceived { get; set; }
private int _activeConnections;
private long _totalConnections;
private long _bytesSent;
private long _bytesReceived;
private long _messagesSent;
private long _messagesReceived;
private long _droppedPackets;
private long _droppedConnections;
public int ActiveConnections
{
get => Volatile.Read(ref _activeConnections);
set => Volatile.Write(ref _activeConnections, value);
}
public long TotalConnections
{
get => Interlocked.Read(ref _totalConnections);
set => Interlocked.Exchange(ref _totalConnections, value);
}
public long BytesSent
{
get => Interlocked.Read(ref _bytesSent);
set => Interlocked.Exchange(ref _bytesSent, value);
}
public long BytesReceived
{
get => Interlocked.Read(ref _bytesReceived);
set => Interlocked.Exchange(ref _bytesReceived, value);
}
public long MessagesSent
{
get => Interlocked.Read(ref _messagesSent);
set => Interlocked.Exchange(ref _messagesSent, value);
}
public long MessagesReceived
{
get => Interlocked.Read(ref _messagesReceived);
set => Interlocked.Exchange(ref _messagesReceived, value);
}
public DateTime StartTime { get; set; }
public TimeSpan Uptime => DateTime.UtcNow - StartTime;
public double MessagesPerSecond => MessagesReceived / Math.Max(1, Uptime.TotalSeconds);
public long DroppedPackets
{
get => Interlocked.Read(ref _droppedPackets);
set => Interlocked.Exchange(ref _droppedPackets, value);
}
public long DroppedConnections
{
get => Interlocked.Read(ref _droppedConnections);
set => Interlocked.Exchange(ref _droppedConnections, value);
}
public void IncrementTotalConnections() => Interlocked.Increment(ref _totalConnections);
public void IncrementDroppedConnections() => Interlocked.Increment(ref _droppedConnections);
public void IncrementDroppedPackets() => Interlocked.Increment(ref _droppedPackets);
public void IncrementMessagesSent() => Interlocked.Increment(ref _messagesSent);
public void IncrementMessagesReceived() => Interlocked.Increment(ref _messagesReceived);
public void AddBytesSent(long bytes) => Interlocked.Add(ref _bytesSent, bytes);
public void AddBytesReceived(long bytes) => Interlocked.Add(ref _bytesReceived, bytes);
public void Dispose()
{
// Nothing to dispose
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,252 +1,313 @@
using EonaCat.Json;
using Heijmans.Connector.Models;
using System;
using EonaCat.Connections;
using EonaCat.Connections.Models;
using System.Collections.Concurrent;
using System.Text;
using System.Timers;
namespace EonaCat.Connections.Processors
public sealed class JsonDataProcessor<TData> : IDisposable
{
public sealed class JsonDataProcessor<TData> : IDisposable
{
private readonly StringBuilder _buffer = new StringBuilder(4096);
private readonly object _sync = new object();
private bool _disposed;
// 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.
private sealed class ClientContext
{
public JsonChunkParser Parser { get; private set; }
public int MaxAllowedBufferSize { get; set; } = 30 * 1024 * 1024;
public int MaxMessagesPerBatch { get; set; } = 200;
public string ClientName { get; }
public long TotalBytesProcessed { get; private set; }
public long TotalChunksReceived { get; private set; }
public string ClientEndpoint { get; set; }
public event EventHandler<ProcessedJsonMessage<TData>>? OnProcessMessage;
public event EventHandler<ProcessedTextMessage>? OnProcessTextMessage;
public event EventHandler<Exception>? OnMessageError;
public DateTime LastActivityUtc { get; set; }
public JsonDataProcessor()
public object Lock { get; } = new();
public EventHandler<JsonChunkParser.NonJsonChunkEvent> NonJsonTextHandler { get; set; }
public EventHandler<JsonChunkParser.JsonChunkEvent> FullJsonHandler { get; set; }
public ClientContext(string name, string endpoint, int maxJsonSize)
{
ClientName = Guid.NewGuid().ToString();
ClientName = name;
ClientEndpoint = endpoint;
Parser = new JsonChunkParser
{
MaxJsonSize = maxJsonSize
};
LastActivityUtc = DateTime.UtcNow;
}
public void Process(string data, string? client = null, string? endpoint = null)
public void UnbindEvents()
{
ThrowIfDisposed();
if (string.IsNullOrEmpty(data))
if (NonJsonTextHandler != null)
{
Parser.NonJsonTextFound -= NonJsonTextHandler;
NonJsonTextHandler = null;
}
if (FullJsonHandler != null)
{
Parser.FullJsonCompleted -= FullJsonHandler;
FullJsonHandler = null;
}
}
public void ResetParser(int maxJsonSize)
{
UnbindEvents();
Parser.Dispose();
Parser = new JsonChunkParser
{
MaxJsonSize = maxJsonSize
};
}
}
public int MaxAllowedBufferSize { get; set; } = 128 * 1024 * 1024;
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(10);
public bool KeepIdleClients { get; set; }
private readonly ConcurrentDictionary<string, ClientContext> _clients = new();
private readonly string _clientName;
private readonly System.Timers.Timer _cleanupTimer;
public event EventHandler<ProcessedMessage<TData>> OnProcessMessage;
public event EventHandler<ProcessedTextMessage> OnProcessTextMessage;
public event EventHandler<Exception> OnError;
public event EventHandler<string> OnClientRemovedDueToIdle;
public JsonDataProcessor(string clientName = null)
{
if (string.IsNullOrWhiteSpace(clientName))
{
clientName = Guid.NewGuid().ToString();
}
_clientName = clientName;
_cleanupTimer = new System.Timers.Timer(60000);
_cleanupTimer.Elapsed += CleanupTimer_Elapsed;
_cleanupTimer.AutoReset = true;
_cleanupTimer.Start();
}
private void CleanupTimer_Elapsed(object sender, ElapsedEventArgs e)
{
if (KeepIdleClients)
{
return;
}
TotalChunksReceived++;
TotalBytesProcessed += Encoding.UTF8.GetByteCount(data);
var now = DateTime.UtcNow;
ProcessInternal(data, client ?? ClientName, endpoint);
foreach (var kvp in _clients)
{
var context = kvp.Value;
if (now - context.LastActivityUtc < IdleTimeout)
{
continue;
}
private void ProcessInternal(string data, string client, string? endpoint)
if (_clients.TryRemove(kvp.Key, out var removed))
{
lock (_sync)
lock (removed.Lock)
{
_buffer.Append(data);
int processed = 0;
while (processed < MaxMessagesPerBatch &&
TryExtract(out int start, out int length, out bool isText))
{
if (isText)
{
var text = _buffer.ToString(start, length);
OnProcessTextMessage?.Invoke(this, new ProcessedTextMessage
{
Text = text,
ClientName = client
});
removed.UnbindEvents();
removed.Parser.Dispose();
}
else
SafeInvoke(() =>
OnClientRemovedDueToIdle?.Invoke(this, removed.ClientName));
}
}
}
public void Process(string jsonChunk, string currentClientName, string clientEndpoint = null)
{
var json = _buffer.ToString(start, length);
if (string.IsNullOrEmpty(jsonChunk))
{
RaiseError(new ArgumentException("Invalid JSON input."));
return;
}
Process(Encoding.UTF8.GetBytes(jsonChunk), currentClientName, clientEndpoint);
}
public void Process(DataReceivedEventArgs data, string currentClientName, string clientEndpoint = null)
{
if (data?.Data == null || data.Data.Length == 0)
{
RaiseError(new ArgumentException("Invalid input data."));
return;
}
if (string.IsNullOrWhiteSpace(clientEndpoint))
{
clientEndpoint = data.RemoteEndPoint?.ToString();
}
Process(data.Data, currentClientName, clientEndpoint);
}
public void Process(byte[] data, string currentClientName, string clientEndpoint = null)
{
if (data == null || data.Length == 0)
{
RaiseError(new ArgumentException("Invalid input data."));
return;
}
if (string.IsNullOrWhiteSpace(currentClientName))
{
currentClientName = _clientName;
}
try
{
var obj = JsonHelper.ToObject<TData>(json);
OnProcessMessage?.Invoke(this, new ProcessedJsonMessage<TData>
var context = _clients.GetOrAdd(currentClientName, name =>
{
Data = obj,
RawData = json,
ClientName = client,
ClientEndpoint = endpoint ?? string.Empty
var ctx = new ClientContext(name, clientEndpoint, MaxAllowedBufferSize);
BindEvents(ctx);
return ctx;
});
lock (context.Lock)
{
if (!string.IsNullOrWhiteSpace(clientEndpoint))
{
context.ClientEndpoint = clientEndpoint;
}
context.LastActivityUtc = DateTime.UtcNow;
context.Parser.Process(data);
if (context.Parser.MaxJsonSize > MaxAllowedBufferSize)
{
context.ResetParser(MaxAllowedBufferSize);
BindEvents(context);
RaiseError(new Exception("Parser reset due to buffer overflow."));
}
}
}
catch (Exception ex)
{
OnMessageError?.Invoke(this,
new Exception($"Failed to parse JSON for {client}", ex));
RaiseError(new Exception($"Could not process chunk: {ex.Message}", ex));
}
}
Consume(start, length);
processed++;
}
if (_buffer.Length > MaxAllowedBufferSize)
private void BindEvents(ClientContext context)
{
OnMessageError?.Invoke(this,
new Exception($"Buffer exceeded {MaxAllowedBufferSize} bytes for client {client}. Discarding."));
_buffer.Clear();
_buffer.Capacity = 4096;
context.UnbindEvents();
context.NonJsonTextHandler = (sender, e) =>
{
if (e == null || e.Text.Length == 0)
{
return;
}
SafeInvoke(() =>
OnProcessTextMessage?.Invoke(this,
new ProcessedTextMessage
{
ClientName = context.ClientName,
ClientEndpoint = context.ClientEndpoint,
Text = e.Text.ToString()
}));
};
context.FullJsonHandler = (sender, e) =>
{
if (e.Json.Length == 0)
{
return;
}
try
{
var data = JsonHelper.ToObject<TData>(e.Json);
SafeInvoke(() =>
OnProcessMessage?.Invoke(this,
new ProcessedMessage<TData>
{
ClientName = context.ClientName,
ClientEndpoint = context.ClientEndpoint,
Data = data
}));
}
catch (Exception ex)
{
RaiseError(new Exception($"JSON parse error: {ex.Message}", ex));
}
};
context.Parser.NonJsonTextFound += context.NonJsonTextHandler;
context.Parser.FullJsonCompleted += context.FullJsonHandler;
}
public void RemoveClient(string clientName)
{
if (string.IsNullOrWhiteSpace(clientName))
{
return;
}
if (_clients.TryRemove(clientName, out var context))
{
lock (context.Lock)
{
context.UnbindEvents();
context.Parser.Dispose();
}
}
}
private bool TryExtract(out int start, out int length, out bool isText)
private void RaiseError(Exception ex)
{
start = length = 0;
isText = false;
if (_buffer.Length == 0)
{
return false;
SafeInvoke(() => OnError?.Invoke(this, ex));
}
var span = _buffer.ToString().AsSpan();
int pos = 0;
while (pos < span.Length && char.IsWhiteSpace(span[pos]))
private static void SafeInvoke(Action action)
{
pos++;
try
{
action?.Invoke();
}
if (pos >= span.Length)
catch
{
return false;
}
char c = span[pos];
if (c != '{' && c != '[')
{
isText = true;
start = pos;
while (pos < span.Length && span[pos] != '{' && span[pos] != '[')
{
pos++;
}
length = pos - start;
return true;
}
start = pos;
length = FindJsonEnd(span, pos) - pos;
if (length <= 0)
{
return false;
}
isText = false;
return true;
}
private static int FindJsonEnd(ReadOnlySpan<char> span, int start)
{
char open = span[start];
char close = open == '{' ? '}' : ']';
int depth = 1;
bool inString = false;
bool escape = false;
for (int i = start + 1; i < span.Length; i++)
{
char c = span[i];
if (inString)
{
if (escape)
{
escape = false;
}
else if (c == '\\')
{
escape = true;
}
else if (c == '"')
{
inString = false;
}
}
else
{
if (c == '"')
{
inString = true;
}
else if (c == open)
{
depth++;
}
else if (c == close && --depth == 0)
{
return i + 1;
}
}
}
return 0;
}
private void Consume(int start, int length)
{
_buffer.Remove(start, length);
if (_buffer.Capacity > 1024 * 1024 && _buffer.Length < _buffer.Capacity / 2)
{
_buffer.Capacity = Math.Max(_buffer.Length, 4096);
}
}
public void ClearBuffer()
{
lock (_sync)
{
_buffer.Clear();
_buffer.Capacity = 4096;
}
}
public void ResetStatistics()
{
lock (_sync)
{
TotalBytesProcessed = 0;
TotalChunksReceived = 0;
// prevent user event handlers from breaking the processor
}
}
public void Dispose()
{
if (_disposed)
_cleanupTimer.Elapsed -= CleanupTimer_Elapsed;
_cleanupTimer.Stop();
_cleanupTimer.Dispose();
foreach (var client in _clients.Values)
{
return;
lock (client.Lock)
{
client.UnbindEvents();
client.Parser.Dispose();
}
}
_disposed = true;
lock (_sync)
{
_buffer.Clear();
_buffer.Capacity = 0;
}
_clients.Clear();
OnProcessMessage = null;
OnProcessTextMessage = null;
OnMessageError = null;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(JsonDataProcessor<TData>));
}
}
OnError = null;
OnClientRemovedDueToIdle = null;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB