Updated
This commit is contained in:
@@ -7,6 +7,14 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace EonaCat.Connections.Client
|
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);
|
// Root myDeserializedClass = JsonConvert.DeserializeObject<List<Root>>(myJsonResponse);
|
||||||
public class Contact
|
public class Contact
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
using EonaCat.Connections.Models;
|
using EonaCat.Connections.Models;
|
||||||
using EonaCat.Connections.Processors;
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace EonaCat.Connections.Client.Example
|
namespace EonaCat.Connections.Client.Example
|
||||||
{
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
public class Program
|
public class Program
|
||||||
{
|
{
|
||||||
private const bool UseProcessor = true;
|
private const bool UseProcessor = true;
|
||||||
@@ -62,7 +64,7 @@ namespace EonaCat.Connections.Client.Example
|
|||||||
{
|
{
|
||||||
foreach (var client in _clients)
|
foreach (var client in _clients)
|
||||||
{
|
{
|
||||||
await client.DisconnectClientAsync().ConfigureAwait(false);
|
await client.DisconnectAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -96,10 +98,9 @@ namespace EonaCat.Connections.Client.Example
|
|||||||
};
|
};
|
||||||
processor.OnProcessMessage += (sender, e) =>
|
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.MaxAllowedBufferSize = 50 * 1024 * 1024; // 10 MB
|
||||||
processor.MaxMessagesPerBatch = 5;
|
|
||||||
var json = _jsonContent;
|
var json = _jsonContent;
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
@@ -155,7 +156,6 @@ namespace EonaCat.Connections.Client.Example
|
|||||||
Protocol = ProtocolType.TCP,
|
Protocol = ProtocolType.TCP,
|
||||||
Host = SERVER_IP,
|
Host = SERVER_IP,
|
||||||
Port = 1111,
|
Port = 1111,
|
||||||
UseSsl = false,
|
|
||||||
UseAesEncryption = false,
|
UseAesEncryption = false,
|
||||||
EnableHeartbeat = IsHeartBeatEnabled,
|
EnableHeartbeat = IsHeartBeatEnabled,
|
||||||
AesPassword = "EonaCat.Connections.Password",
|
AesPassword = "EonaCat.Connections.Password",
|
||||||
@@ -175,16 +175,11 @@ namespace EonaCat.Connections.Client.Example
|
|||||||
if (UseProcessor)
|
if (UseProcessor)
|
||||||
{
|
{
|
||||||
_clientsProcessors[client] = new JsonDataProcessor<List<Root>>();
|
_clientsProcessors[client] = new JsonDataProcessor<List<Root>>();
|
||||||
_clientsProcessors[client].OnMessageError += (sender, e) =>
|
_clientsProcessors[client].OnError += (sender, e) =>
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Processor error: {e.Message}");
|
Console.WriteLine($"Processor error: {e.Message}");
|
||||||
};
|
};
|
||||||
|
|
||||||
_clientsProcessors[client].OnMessageError += (sender, e) =>
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Processor message error: {e.Message}");
|
|
||||||
};
|
|
||||||
|
|
||||||
_clientsProcessors[client].OnProcessTextMessage += (sender, e) =>
|
_clientsProcessors[client].OnProcessTextMessage += (sender, e) =>
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Processed text message from {e.ClientName}: {e.Text}");
|
Console.WriteLine($"Processed text message from {e.ClientName}: {e.Text}");
|
||||||
@@ -192,7 +187,7 @@ namespace EonaCat.Connections.Client.Example
|
|||||||
|
|
||||||
_clientsProcessors[client].OnProcessMessage += (sender, e) =>
|
_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)
|
if (UseProcessor)
|
||||||
{
|
{
|
||||||
_clientsProcessors[client].Process(e.StringData, clientName: e.Nickname);
|
_clientsProcessors[client].Process(e.StringData, currentClientName: e.Nickname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
<ProjectReference Include="..\EonaCat.Connections\EonaCat.Connections.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
using EonaCat.Connections.Models;
|
||||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
|
||||||
|
|
||||||
using EonaCat.Connections.Models;
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace EonaCat.Connections.Server.Example
|
namespace EonaCat.Connections.Server.Example
|
||||||
{
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
public class Program
|
public class Program
|
||||||
{
|
{
|
||||||
private const bool IsHeartBeatEnabled = false;
|
private const bool IsHeartBeatEnabled = false;
|
||||||
@@ -52,7 +52,6 @@ namespace EonaCat.Connections.Server.Example
|
|||||||
Protocol = ProtocolType.TCP,
|
Protocol = ProtocolType.TCP,
|
||||||
Host = "0.0.0.0",
|
Host = "0.0.0.0",
|
||||||
Port = 1111,
|
Port = 1111,
|
||||||
UseSsl = false,
|
|
||||||
UseAesEncryption = false,
|
UseAesEncryption = false,
|
||||||
MaxConnections = 100000,
|
MaxConnections = 100000,
|
||||||
AesPassword = "EonaCat.Connections.Password",
|
AesPassword = "EonaCat.Connections.Password",
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
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.
|
// 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
|
|
||||||
{
|
|
||||||
internal enum BufferSizeMaximum
|
internal enum BufferSizeMaximum
|
||||||
{
|
{
|
||||||
Minimal = 8192,
|
Minimal = 8192,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
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.
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
namespace EonaCat.Connections
|
|
||||||
{
|
|
||||||
public enum DisconnectReason
|
public enum DisconnectReason
|
||||||
{
|
{
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|||||||
@@ -39,9 +39,9 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="EonaCat.Json" Version="1.2.0" />
|
<PackageReference Include="EonaCat.Json" Version="2.2.3" />
|
||||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Primitives" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Primitives" Version="10.0.9" />
|
||||||
<PackageReference Include="System.Buffers" Version="4.6.1" />
|
<PackageReference Include="System.Buffers" Version="4.6.1" />
|
||||||
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
// 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.
|
// 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 class DataReceivedEventArgs : EventArgs
|
||||||
{
|
{
|
||||||
public string ClientId { get; internal set; }
|
public string ClientId { get; set; }
|
||||||
public byte[] Data { get; internal set; }
|
public byte[] Data { get; set; }
|
||||||
public string StringData { get; internal set; }
|
public string StringData => Data != null ? Encoding.UTF8.GetString(Data) : string.Empty;
|
||||||
public bool IsBinary { get; internal set; }
|
public bool IsBinary { get; internal set; }
|
||||||
public DateTime Timestamp { get; internal set; } = DateTime.UtcNow;
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
public IPEndPoint RemoteEndPoint { get; internal set; }
|
public IPEndPoint RemoteEndPoint { get; set; }
|
||||||
public string Nickname { get; internal set; }
|
public string Nickname { get; set; }
|
||||||
public bool HasNickname => !string.IsNullOrWhiteSpace(Nickname);
|
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.
|
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.
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
namespace EonaCat.Connections.EventArguments
|
|
||||||
{
|
|
||||||
public class ErrorEventArgs : EventArgs
|
public class ErrorEventArgs : EventArgs
|
||||||
{
|
{
|
||||||
public string ClientId { get; set; }
|
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;
|
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
|
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 class PingEventArgs : EventArgs
|
||||||
{
|
{
|
||||||
public string Id { get; set; }
|
public string Id { get; set; }
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.Helpers
|
||||||
|
{
|
||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
// 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.
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
namespace EonaCat.Connections.Helpers
|
|
||||||
{
|
|
||||||
public static class AesKeyExchange
|
public static class AesKeyExchange
|
||||||
{
|
{
|
||||||
// 256-bit salt
|
// 256-bit salt
|
||||||
@@ -25,27 +25,21 @@ namespace EonaCat.Connections.Helpers
|
|||||||
|
|
||||||
private static readonly byte[] KeyConfirmationLabel = Encoding.UTF8.GetBytes("KEYCONFIRMATION");
|
private static readonly byte[] KeyConfirmationLabel = Encoding.UTF8.GetBytes("KEYCONFIRMATION");
|
||||||
|
|
||||||
public static async Task<int> EncryptDataAsync(byte[] buffer, int bytesToSend, Aes aes)
|
// AES block size for PKCS7 padding
|
||||||
{
|
public static int MaxEncryptionOverhead => 16;
|
||||||
using (var encryptor = aes.CreateEncryptor())
|
|
||||||
{
|
|
||||||
byte[] encrypted = await Task.Run(() => encryptor.TransformFinalBlock(buffer, 0, bytesToSend)).ConfigureAwait(false);
|
|
||||||
|
|
||||||
Buffer.BlockCopy(encrypted, 0, buffer, 0, encrypted.Length);
|
public static Task<byte[]> EncryptDataAsync(byte[] data, int length, Aes aes)
|
||||||
return encrypted.Length;
|
{
|
||||||
}
|
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())
|
using var decryptor = aes.CreateDecryptor();
|
||||||
{
|
byte[] decrypted = decryptor.TransformFinalBlock(data, 0, length);
|
||||||
byte[] decrypted = await Task.Run(() => decryptor.TransformFinalBlock(buffer, 0, bytesToSend)).ConfigureAwait(false);
|
return Task.FromResult(decrypted);
|
||||||
|
|
||||||
Buffer.BlockCopy(decrypted, 0, buffer, 0, decrypted.Length);
|
|
||||||
|
|
||||||
return decrypted.Length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<Aes> SendAesKeyAsync(Stream stream, Aes aes, string password)
|
public static async Task<Aes> SendAesKeyAsync(Stream stream, Aes aes, string password)
|
||||||
@@ -230,6 +224,9 @@ namespace EonaCat.Connections.Helpers
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if NET5_0_OR_GREATER
|
||||||
|
return CryptographicOperations.FixedTimeEquals(firstByteArray, secondByteArray);
|
||||||
|
#else
|
||||||
int difference = 0;
|
int difference = 0;
|
||||||
for (int i = 0; i < firstByteArray.Length; i++)
|
for (int i = 0; i < firstByteArray.Length; i++)
|
||||||
{
|
{
|
||||||
@@ -237,6 +234,7 @@ namespace EonaCat.Connections.Helpers
|
|||||||
}
|
}
|
||||||
|
|
||||||
return difference == 0;
|
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 ? "✔ Network is Stable" : "⚠ 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\">✔</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 — 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("&", "&")
|
||||||
|
.Replace("<", "<")
|
||||||
|
.Replace(">", ">")
|
||||||
|
.Replace("\"", """)
|
||||||
|
.Replace("'", "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Text;
|
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
|
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 class TcpSeparators
|
||||||
{
|
{
|
||||||
public static byte[] NewLine => Encoding.UTF8.GetBytes("\n");
|
public static byte[] NewLine => Encoding.UTF8.GetBytes("\n");
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace EonaCat.Connections.Models
|
||||||
|
{
|
||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
// 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.
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
namespace EonaCat.Connections.Models
|
public class Configuration : IDisposable
|
||||||
{
|
|
||||||
public class Configuration
|
|
||||||
{
|
{
|
||||||
public event EventHandler<string> OnLog;
|
public event EventHandler<string> OnLog;
|
||||||
public List<string> TrustedThumbprints = new List<string>();
|
public List<string> TrustedThumbprints = new List<string>();
|
||||||
@@ -15,8 +16,27 @@ namespace EonaCat.Connections.Models
|
|||||||
public int ReconnectDelayInSeconds { get; set; } = 5;
|
public int ReconnectDelayInSeconds { get; set; } = 5;
|
||||||
public int MaxReconnectAttempts { get; set; } = 0; // 0 means unlimited attempts
|
public int MaxReconnectAttempts { get; set; } = 0; // 0 means unlimited attempts
|
||||||
public int SSLMaxRetries { get; set; } = 0; // 0 means unlimited attempts
|
public int SSLMaxRetries { get; set; } = 0; // 0 means unlimited attempts
|
||||||
public int SSLTimeoutInSeconds { get; set; } = 10;
|
public int SSLTimeoutInSeconds { get; set; } = 35;
|
||||||
public int SSLRetryDelayInSeconds { get; set; } = 2;
|
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 FramingMode MessageFraming { get; set; } = FramingMode.None;
|
||||||
public byte[] Delimiter { get; internal set; } = Helpers.TcpSeparators.Percent;
|
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 PING_VALUE = "¯";
|
||||||
public const string PONG_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 ProtocolType Protocol { get; set; } = ProtocolType.TCP;
|
||||||
public int Port { get; set; } = 8080;
|
public int Port { get; set; } = 8080;
|
||||||
public string Host { get; set; } = "127.0.0.1";
|
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 X509Certificate2 Certificate { get; set; }
|
||||||
|
public X509Certificate2Collection AdditionalCertificates { get; set; }
|
||||||
public bool UseAesEncryption { get; set; } = false;
|
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 int MaxConnections { get; set; } = 100000;
|
||||||
public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
public bool EnableKeepAlive { get; set; } = true;
|
public bool EnableKeepAlive { get; set; } = true;
|
||||||
|
public 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;
|
public bool EnableNagle { get; set; } = false;
|
||||||
|
|
||||||
// For testing purposes, allow self-signed certificates
|
// For testing purposes, allow self-signed certificates
|
||||||
@@ -44,13 +75,89 @@ namespace EonaCat.Connections.Models
|
|||||||
public bool CheckAgainstInternalTrustedCertificates { get; private set; } = true;
|
public bool CheckAgainstInternalTrustedCertificates { get; private set; } = true;
|
||||||
public bool CheckCertificateRevocation { get; set; }
|
public bool CheckCertificateRevocation { get; set; }
|
||||||
public bool MutuallyAuthenticate { get; set; } = true;
|
public bool MutuallyAuthenticate { get; set; } = true;
|
||||||
public double ClientTimeoutInMinutes { get; set; } = 10;
|
|
||||||
public bool EnableHeartbeat { get; set; }
|
public bool EnableHeartbeat { get; set; }
|
||||||
|
|
||||||
|
public bool AllowTlsRenegotiation { get; set; }
|
||||||
|
|
||||||
public bool UseBigEndian { 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 bool EnablePingPongLogs { get; set; }
|
||||||
public int MAX_MESSAGE_SIZE { get; set; } = 100 * 1024 * 1024; // 100 MB
|
public int MAX_MESSAGE_SIZE { get; set; } = 100 * 1024 * 1024; // 100 MB
|
||||||
public bool DisconectOnMissedPong { get; set; }
|
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()
|
internal RemoteCertificateValidationCallback GetRemoteCertificateValidationCallback()
|
||||||
{
|
{
|
||||||
@@ -67,15 +174,27 @@ namespace EonaCat.Connections.Models
|
|||||||
{
|
{
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
bool result = false;
|
||||||
|
string reason = "Unknown";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (IsSelfSignedEnabled)
|
if (certificate == null)
|
||||||
{
|
{
|
||||||
OnLog?.Invoke(this, $"WARNING: Accepting all invalid certificates: {certificate?.Subject}");
|
reason = "Certificate is null";
|
||||||
return true;
|
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.RevocationMode = X509RevocationMode.NoCheck;
|
||||||
chain.ChainPolicy.VerificationFlags =
|
chain.ChainPolicy.VerificationFlags =
|
||||||
@@ -83,69 +202,133 @@ namespace EonaCat.Connections.Models
|
|||||||
X509VerificationFlags.IgnoreEndRevocationUnknown |
|
X509VerificationFlags.IgnoreEndRevocationUnknown |
|
||||||
X509VerificationFlags.AllowUnknownCertificateAuthority;
|
X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||||
|
|
||||||
chain.Build((X509Certificate2)certificate);
|
chain.Build(cert);
|
||||||
|
|
||||||
foreach (var status in chain.ChainStatus)
|
foreach (var status in chain.ChainStatus)
|
||||||
{
|
{
|
||||||
OnLog?.Invoke(this, $"ChainStatus: {status.Status} - {status.StatusInformation}");
|
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)
|
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||||
{
|
{
|
||||||
OnLog?.Invoke(this, $"Certificate validation succeeded in {stopwatch.ElapsedMilliseconds} ms");
|
reason = "Certificate validation succeeded";
|
||||||
return true;
|
result = true;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CheckAgainstInternalTrustedCertificates && certificate is X509Certificate2 cert2)
|
if (CheckAgainstInternalTrustedCertificates && certificate is X509Certificate2 cert2)
|
||||||
{
|
{
|
||||||
string thumbprint = cert2.Thumbprint?.Replace(" ", "").ToLowerInvariant();
|
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}");
|
reason = $"Trusted thumbprint matched: {thumbprint}";
|
||||||
return true;
|
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)
|
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors) && chain != null)
|
||||||
{
|
{
|
||||||
bool fatal = false;
|
bool fatal = false;
|
||||||
|
|
||||||
foreach (var status in chain.ChainStatus)
|
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;
|
fatal = true;
|
||||||
|
reason = $"Fatal chain error: {status.Status} - {status.StatusInformation}";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.Status == X509ChainStatusFlags.NotSignatureValid)
|
if (fatal)
|
||||||
{
|
{
|
||||||
OnLog?.Invoke(this, $"Invalid signature: {status.StatusInformation}");
|
|
||||||
fatal = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fatal)
|
if (!fatal)
|
||||||
{
|
{
|
||||||
OnLog?.Invoke(this, $"Certificate accepted (ignoring minor chain warnings)");
|
reason = "Certificate accepted (ignoring non-fatal chain warnings)";
|
||||||
return true;
|
result = true;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
OnLog?.Invoke(this, $"Certificate validation failed (Validation took {stopwatch.ElapsedMilliseconds} ms)");
|
result = false;
|
||||||
return false;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
OnLog?.Invoke(this, $"Certificate rejected: {sslPolicyErrors} (Validation took {stopwatch.ElapsedMilliseconds} ms)");
|
reason = $"Certificate rejected: {sslPolicyErrors}";
|
||||||
return false;
|
result = false;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
reason = $"Certificate validation exception: {ex}";
|
||||||
|
result = false;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
stopwatch.Stop();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,32 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
using System.Threading.Channels;
|
||||||
// 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 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 string Id { get; set; }
|
||||||
public TcpClient TcpClient { get; set; }
|
public TcpClient TcpClient { get; set; }
|
||||||
public UdpClient UdpClient { get; set; }
|
public UdpClient UdpClient { get; set; }
|
||||||
public IPEndPoint RemoteEndPoint { get; set; }
|
public IPEndPoint RemoteEndPoint { get; set; }
|
||||||
public Stream Stream { 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;
|
private string _nickName;
|
||||||
|
|
||||||
public string Nickname
|
public string Nickname
|
||||||
{
|
{
|
||||||
get
|
get => string.IsNullOrWhiteSpace(_nickName) ? Id : _nickName;
|
||||||
{
|
set => _nickName = string.IsNullOrWhiteSpace(value) ? Id : value;
|
||||||
if (string.IsNullOrWhiteSpace(_nickName))
|
|
||||||
{
|
|
||||||
_nickName = Id;
|
|
||||||
}
|
|
||||||
return _nickName;
|
|
||||||
}
|
|
||||||
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
|
||||||
{
|
|
||||||
_nickName = Id;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_nickName = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id;
|
public bool HasNickname => !string.IsNullOrWhiteSpace(_nickName) && _nickName != Id;
|
||||||
@@ -49,18 +37,29 @@ namespace EonaCat.Connections.Models
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (TcpClient != null && TcpClient.Client != null)
|
if (TcpClient?.Client == null)
|
||||||
{
|
|
||||||
return !(TcpClient.Client.Poll(1, SelectMode.SelectRead) && TcpClient.Client.Available == 0);
|
|
||||||
}
|
|
||||||
else if (UdpClient != null)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
return false;
|
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
|
catch
|
||||||
{
|
{
|
||||||
@@ -70,139 +69,98 @@ namespace EonaCat.Connections.Models
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DateTime ConnectedAt { get; internal set; }
|
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 DisconnectionTime { get; internal set; }
|
||||||
public DateTime LastDataSent { get; internal set; }
|
public DateTime LastDataSent { get; internal set; }
|
||||||
public DateTime LastDataReceived { 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();
|
SingleReader = true,
|
||||||
return (int)idleTime.TotalSeconds;
|
SingleWriter = false,
|
||||||
}
|
//FullMode = BoundedChannelFullMode.DropOldest
|
||||||
|
});
|
||||||
|
|
||||||
public int IdleTimeInMinutes()
|
public TimeSpan IdleTime() => DateTime.UtcNow - LastActive;
|
||||||
{
|
|
||||||
var idleTime = IdleTime();
|
|
||||||
return (int)idleTime.TotalMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int IdleTimeInHours()
|
public int IdleTimeInSeconds() => (int)IdleTime().TotalSeconds;
|
||||||
{
|
|
||||||
var idleTime = IdleTime();
|
|
||||||
return (int)idleTime.TotalHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int IdleTimeInDays()
|
public int IdleTimeInMinutes() => (int)IdleTime().TotalMinutes;
|
||||||
{
|
|
||||||
var idleTime = IdleTime();
|
|
||||||
return (int)idleTime.TotalDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TimeSpan IdleTime()
|
public int IdleTimeInHours() => (int)IdleTime().TotalHours;
|
||||||
{
|
|
||||||
return DateTime.UtcNow - LastActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>();
|
var parts = new List<string>();
|
||||||
|
|
||||||
if (includeDays)
|
if (includeDays)
|
||||||
{
|
{
|
||||||
parts.Add($"{(int)idleTime.TotalDays:D2}d");
|
parts.Add($"{(int)span.TotalDays:D2}d");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeHours)
|
if (includeHours)
|
||||||
{
|
{
|
||||||
parts.Add($"{idleTime.Hours:D2}h");
|
parts.Add($"{span.Hours:D2}h");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeMinutes)
|
if (includeMinutes)
|
||||||
{
|
{
|
||||||
parts.Add($"{idleTime.Minutes:D2}m");
|
parts.Add($"{span.Minutes:D2}m");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeSeconds)
|
if (includeSeconds)
|
||||||
{
|
{
|
||||||
parts.Add($"{idleTime.Seconds:D2}s");
|
parts.Add($"{span.Seconds:D2}s");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeMilliseconds)
|
if (includeMilliseconds)
|
||||||
{
|
{
|
||||||
parts.Add($"{idleTime.Milliseconds:D3}ms");
|
parts.Add($"{span.Milliseconds:D3}ms");
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Join(" ", parts);
|
return string.Join(" ", parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int ConnectedTimeInSeconds()
|
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);
|
||||||
var connectedTime = DateTime.UtcNow - ConnectedAt;
|
|
||||||
return (int)connectedTime.TotalSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int ConnectedTimeInMinutes()
|
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);
|
||||||
var connectedTime = DateTime.UtcNow - ConnectedAt;
|
|
||||||
return (int)connectedTime.TotalMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int ConnectedTimeInHours()
|
|
||||||
{
|
|
||||||
var connectedTime = DateTime.UtcNow - ConnectedAt;
|
|
||||||
return (int)connectedTime.TotalHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int ConnectedTimeInDays()
|
|
||||||
{
|
|
||||||
var connectedTime = DateTime.UtcNow - ConnectedAt;
|
|
||||||
return (int)connectedTime.TotalDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TimeSpan ConnectedTime()
|
|
||||||
{
|
|
||||||
return DateTime.UtcNow - ConnectedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ConnectedTimeFormatted(bool includeDays = true, bool includeHours = true, bool includeMinutes = true, bool includeSeconds = true, bool includeMilliseconds = true)
|
|
||||||
{
|
|
||||||
var connectedTime = ConnectedTime();
|
|
||||||
var parts = new List<string>();
|
|
||||||
|
|
||||||
if (includeDays)
|
|
||||||
{
|
|
||||||
parts.Add($"{(int)connectedTime.TotalDays:D2}d");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeHours)
|
|
||||||
{
|
|
||||||
parts.Add($"{connectedTime.Hours:D2}h");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeMinutes)
|
|
||||||
{
|
|
||||||
parts.Add($"{connectedTime.Minutes:D2}m");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeSeconds)
|
|
||||||
{
|
|
||||||
parts.Add($"{connectedTime.Seconds:D2}s");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeMilliseconds)
|
|
||||||
{
|
|
||||||
parts.Add($"{connectedTime.Milliseconds:D3}ms");
|
|
||||||
}
|
|
||||||
return string.Join(" ", parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsSecure { get; set; }
|
public bool IsSecure { get; set; }
|
||||||
public bool IsEncrypted { get; set; }
|
public bool IsEncrypted { get; set; }
|
||||||
public Aes AesEncryption { get; set; }
|
public Aes AesEncryption { get; set; }
|
||||||
public CancellationTokenSource CancellationToken { get; set; }
|
public CancellationTokenSource CancellationToken { get; set; }
|
||||||
|
|
||||||
private long _bytesReceived;
|
private long _bytesReceived;
|
||||||
private long _bytesSent;
|
private long _bytesSent;
|
||||||
|
|
||||||
public long BytesReceived => Interlocked.Read(ref _bytesReceived);
|
public long BytesReceived => Interlocked.Read(ref _bytesReceived);
|
||||||
public long BytesSent => Interlocked.Read(ref _bytesSent);
|
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 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;
|
private int _disconnected;
|
||||||
|
|
||||||
public bool MarkDisconnected() => Interlocked.Exchange(ref _disconnected, 1) == 0;
|
public bool MarkDisconnected() => Interlocked.Exchange(ref _disconnected, 1) == 0;
|
||||||
|
|
||||||
public Dictionary<string, object> Metadata { get; } = new Dictionary<string, object>();
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
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.
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
namespace EonaCat.Connections.Models
|
|
||||||
{
|
|
||||||
public enum FramingMode
|
public enum FramingMode
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
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.
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
namespace EonaCat.Connections.Models
|
public class ProcessedMessage<TData> : IDisposable
|
||||||
{
|
|
||||||
public class ProcessedMessage<TData>
|
|
||||||
{
|
{
|
||||||
public TData Data { get; set; }
|
public TData Data { get; set; }
|
||||||
public string RawData { get; set; }
|
|
||||||
public string ClientName { get; set; }
|
public string ClientName { get; set; }
|
||||||
public string? ClientEndpoint { get; set; }
|
public string? ClientEndpoint { get; set; }
|
||||||
public bool HasClientEndpoint => !string.IsNullOrEmpty(ClientEndpoint);
|
public bool HasClientEndpoint => !string.IsNullOrEmpty(ClientEndpoint);
|
||||||
public bool HasRawData => !string.IsNullOrEmpty(RawData);
|
|
||||||
public bool HasObject => Data != null;
|
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.
|
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.
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
namespace EonaCat.Connections.Models
|
public class ProcessedTextMessage : IDisposable
|
||||||
{
|
|
||||||
public class ProcessedTextMessage
|
|
||||||
{
|
{
|
||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
public string ClientName { get; set; }
|
public string ClientName { get; set; }
|
||||||
public string? ClientEndpoint { get; set; }
|
public string? ClientEndpoint { get; set; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
// Nothing to dispose
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
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.
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
|
|
||||||
namespace EonaCat.Connections.Models
|
|
||||||
{
|
|
||||||
public enum ProtocolType
|
public enum ProtocolType
|
||||||
{
|
{
|
||||||
TCP,
|
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\">🔒</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>✔ 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 — Auto-refresh every {_config.ServerStatusPageIntervalSeconds}s — 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("&", "&")
|
||||||
|
.Replace("<", "<")
|
||||||
|
.Replace(">", ">")
|
||||||
|
.Replace("\"", """)
|
||||||
|
.Replace("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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\">✔</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 — 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("&", "&")
|
||||||
|
.Replace("<", "<")
|
||||||
|
.Replace(">", ">")
|
||||||
|
.Replace("\"", """)
|
||||||
|
.Replace("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
StopAutoHtmlReport();
|
||||||
|
_autoReportCts?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,84 @@
|
|||||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
using System.Threading;
|
||||||
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
|
||||||
|
|
||||||
namespace EonaCat.Connections
|
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; }
|
private int _activeConnections;
|
||||||
public long TotalConnections { get; set; }
|
private long _totalConnections;
|
||||||
public long BytesSent { get; set; }
|
private long _bytesSent;
|
||||||
public long BytesReceived { get; set; }
|
private long _bytesReceived;
|
||||||
public long MessagesSent { get; set; }
|
private long _messagesSent;
|
||||||
public long MessagesReceived { get; set; }
|
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 DateTime StartTime { get; set; }
|
||||||
public TimeSpan Uptime => DateTime.UtcNow - StartTime;
|
public TimeSpan Uptime => DateTime.UtcNow - StartTime;
|
||||||
public double MessagesPerSecond => MessagesReceived / Math.Max(1, Uptime.TotalSeconds);
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1756
-625
File diff suppressed because it is too large
Load Diff
+1905
-765
File diff suppressed because it is too large
Load Diff
@@ -1,252 +1,313 @@
|
|||||||
using EonaCat.Json;
|
using EonaCat.Json;
|
||||||
using Heijmans.Connector.Models;
|
using EonaCat.Connections;
|
||||||
using System;
|
using EonaCat.Connections.Models;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Text;
|
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);
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
private readonly object _sync = new object();
|
// See the LICENSE file or go to https://EonaCat.com/license for full license details.
|
||||||
private bool _disposed;
|
|
||||||
|
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 string ClientName { get; }
|
||||||
|
|
||||||
public long TotalBytesProcessed { get; private set; }
|
public string ClientEndpoint { get; set; }
|
||||||
public long TotalChunksReceived { get; private set; }
|
|
||||||
|
|
||||||
public event EventHandler<ProcessedJsonMessage<TData>>? OnProcessMessage;
|
public DateTime LastActivityUtc { get; set; }
|
||||||
public event EventHandler<ProcessedTextMessage>? OnProcessTextMessage;
|
|
||||||
public event EventHandler<Exception>? OnMessageError;
|
|
||||||
|
|
||||||
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 (NonJsonTextHandler != null)
|
||||||
if (string.IsNullOrEmpty(data))
|
{
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
TotalChunksReceived++;
|
var now = DateTime.UtcNow;
|
||||||
TotalBytesProcessed += Encoding.UTF8.GetByteCount(data);
|
|
||||||
|
|
||||||
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);
|
removed.UnbindEvents();
|
||||||
|
removed.Parser.Dispose();
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
var obj = JsonHelper.ToObject<TData>(json);
|
var context = _clients.GetOrAdd(currentClientName, name =>
|
||||||
OnProcessMessage?.Invoke(this, new ProcessedJsonMessage<TData>
|
|
||||||
{
|
{
|
||||||
Data = obj,
|
var ctx = new ClientContext(name, clientEndpoint, MaxAllowedBufferSize);
|
||||||
RawData = json,
|
BindEvents(ctx);
|
||||||
ClientName = client,
|
return ctx;
|
||||||
ClientEndpoint = endpoint ?? string.Empty
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
OnMessageError?.Invoke(this,
|
RaiseError(new Exception($"Could not process chunk: {ex.Message}", ex));
|
||||||
new Exception($"Failed to parse JSON for {client}", ex));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Consume(start, length);
|
private void BindEvents(ClientContext context)
|
||||||
processed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_buffer.Length > MaxAllowedBufferSize)
|
|
||||||
{
|
{
|
||||||
OnMessageError?.Invoke(this,
|
context.UnbindEvents();
|
||||||
new Exception($"Buffer exceeded {MaxAllowedBufferSize} bytes for client {client}. Discarding."));
|
|
||||||
_buffer.Clear();
|
context.NonJsonTextHandler = (sender, e) =>
|
||||||
_buffer.Capacity = 4096;
|
{
|
||||||
|
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;
|
SafeInvoke(() => OnError?.Invoke(this, ex));
|
||||||
isText = false;
|
|
||||||
|
|
||||||
if (_buffer.Length == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var span = _buffer.ToString().AsSpan();
|
private static void SafeInvoke(Action action)
|
||||||
int pos = 0;
|
|
||||||
|
|
||||||
while (pos < span.Length && char.IsWhiteSpace(span[pos]))
|
|
||||||
{
|
{
|
||||||
pos++;
|
try
|
||||||
|
{
|
||||||
|
action?.Invoke();
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
if (pos >= span.Length)
|
|
||||||
{
|
{
|
||||||
return false;
|
// prevent user event handlers from breaking the processor
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
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;
|
_clients.Clear();
|
||||||
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
_buffer.Clear();
|
|
||||||
_buffer.Capacity = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnProcessMessage = null;
|
OnProcessMessage = null;
|
||||||
OnProcessTextMessage = null;
|
OnProcessTextMessage = null;
|
||||||
OnMessageError = null;
|
OnError = null;
|
||||||
}
|
OnClientRemovedDueToIdle = null;
|
||||||
|
|
||||||
private void ThrowIfDisposed()
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
throw new ObjectDisposedException(nameof(JsonDataProcessor<TData>));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
Reference in New Issue
Block a user