Add unit tests for Router configuration and transport layers
- Implemented tests for RouterConfig, RoutingOptions, StaticInstanceConfig, and RouterConfigOptions to ensure default values are set correctly. - Added tests for RouterConfigProvider to validate configurations and ensure defaults are returned when no file is specified. - Created tests for ConfigValidationResult to check success and error scenarios. - Developed tests for ServiceCollectionExtensions to verify service registration for RouterConfig. - Introduced UdpTransportTests to validate serialization, connection, request-response, and error handling in UDP transport. - Added scripts for signing authority gaps and hashing DevPortal SDK snippets.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Router.Transport.Udp;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a payload exceeds the maximum datagram size.
|
||||
/// </summary>
|
||||
public sealed class PayloadTooLargeException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the actual size of the payload.
|
||||
/// </summary>
|
||||
public int ActualSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum allowed size.
|
||||
/// </summary>
|
||||
public int MaxSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PayloadTooLargeException"/> class.
|
||||
/// </summary>
|
||||
public PayloadTooLargeException(int actualSize, int maxSize)
|
||||
: base($"Payload size {actualSize} exceeds maximum datagram size of {maxSize} bytes")
|
||||
{
|
||||
ActualSize = actualSize;
|
||||
MaxSize = maxSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
|
||||
namespace StellaOps.Router.Transport.Udp;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering UDP transport services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds UDP transport server services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddUdpTransportServer(
|
||||
this IServiceCollection services,
|
||||
Action<UdpTransportOptions>? configure = null)
|
||||
{
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddSingleton<UdpTransportServer>();
|
||||
services.AddSingleton<ITransportServer>(sp => sp.GetRequiredService<UdpTransportServer>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds UDP transport client services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddUdpTransportClient(
|
||||
this IServiceCollection services,
|
||||
Action<UdpTransportOptions>? configure = null)
|
||||
{
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddSingleton<UdpTransportClient>();
|
||||
services.AddSingleton<ITransportClient>(sp => sp.GetRequiredService<UdpTransportClient>());
|
||||
services.AddSingleton<IMicroserviceTransport>(sp => sp.GetRequiredService<UdpTransportClient>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Router.Transport.Udp</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,79 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.Udp;
|
||||
|
||||
/// <summary>
|
||||
/// Handles serialization and deserialization of frames for UDP transport.
|
||||
/// Frame format: [1-byte frame type][16-byte correlation GUID][remaining data]
|
||||
/// </summary>
|
||||
public static class UdpFrameProtocol
|
||||
{
|
||||
private const int FrameTypeSize = 1;
|
||||
private const int CorrelationIdSize = 16;
|
||||
private const int HeaderSize = FrameTypeSize + CorrelationIdSize;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a frame from a datagram.
|
||||
/// </summary>
|
||||
/// <param name="data">The datagram data.</param>
|
||||
/// <returns>The parsed frame.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the datagram is too small.</exception>
|
||||
public static Frame ParseFrame(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length < HeaderSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Datagram too small: {data.Length} bytes, minimum is {HeaderSize}");
|
||||
}
|
||||
|
||||
var frameType = (FrameType)data[0];
|
||||
var correlationId = new Guid(data.Slice(FrameTypeSize, CorrelationIdSize));
|
||||
var payload = data.Length > HeaderSize
|
||||
? data[HeaderSize..].ToArray()
|
||||
: Array.Empty<byte>();
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = frameType,
|
||||
CorrelationId = correlationId.ToString("N"),
|
||||
Payload = payload
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a frame to a datagram.
|
||||
/// </summary>
|
||||
/// <param name="frame">The frame to serialize.</param>
|
||||
/// <returns>The serialized datagram bytes.</returns>
|
||||
public static byte[] SerializeFrame(Frame frame)
|
||||
{
|
||||
// Parse or generate correlation ID
|
||||
var correlationGuid = frame.CorrelationId is not null &&
|
||||
Guid.TryParse(frame.CorrelationId, out var parsed)
|
||||
? parsed
|
||||
: Guid.NewGuid();
|
||||
|
||||
var payloadLength = frame.Payload.Length;
|
||||
var buffer = new byte[HeaderSize + payloadLength];
|
||||
|
||||
// Write frame type
|
||||
buffer[0] = (byte)frame.Type;
|
||||
|
||||
// Write correlation ID
|
||||
correlationGuid.TryWriteBytes(buffer.AsSpan(FrameTypeSize, CorrelationIdSize));
|
||||
|
||||
// Write payload
|
||||
if (payloadLength > 0)
|
||||
{
|
||||
frame.Payload.Span.CopyTo(buffer.AsSpan(HeaderSize));
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the header size for UDP frames.
|
||||
/// </summary>
|
||||
public static int GetHeaderSize() => HeaderSize;
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.Udp;
|
||||
|
||||
/// <summary>
|
||||
/// UDP transport client implementation for microservices.
|
||||
/// UDP transport does not support streaming.
|
||||
/// </summary>
|
||||
public sealed class UdpTransportClient : ITransportClient, IMicroserviceTransport, IAsyncDisposable
|
||||
{
|
||||
private readonly UdpTransportOptions _options;
|
||||
private readonly ILogger<UdpTransportClient> _logger;
|
||||
private readonly ConcurrentDictionary<Guid, TaskCompletionSource<Frame>> _pendingRequests = new();
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _inflightHandlers = new();
|
||||
private readonly CancellationTokenSource _clientCts = new();
|
||||
private UdpClient? _client;
|
||||
private Task? _receiveTask;
|
||||
private bool _disposed;
|
||||
private string? _connectionId;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a REQUEST frame is received.
|
||||
/// </summary>
|
||||
public event Func<Frame, CancellationToken, Task<Frame>>? OnRequestReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a CANCEL frame is received.
|
||||
/// </summary>
|
||||
public event Func<Guid, string?, Task>? OnCancelReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UdpTransportClient"/> class.
|
||||
/// </summary>
|
||||
public UdpTransportClient(
|
||||
IOptions<UdpTransportOptions> options,
|
||||
ILogger<UdpTransportClient> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the gateway.
|
||||
/// </summary>
|
||||
/// <param name="instance">The instance descriptor.</param>
|
||||
/// <param name="endpoints">The endpoints to register.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task ConnectAsync(
|
||||
InstanceDescriptor instance,
|
||||
IReadOnlyList<EndpointDescriptor> endpoints,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (string.IsNullOrEmpty(_options.Host))
|
||||
{
|
||||
throw new InvalidOperationException("Host is not configured");
|
||||
}
|
||||
|
||||
_client = new UdpClient
|
||||
{
|
||||
EnableBroadcast = _options.AllowBroadcast
|
||||
};
|
||||
_client.Client.ReceiveBufferSize = _options.ReceiveBufferSize;
|
||||
_client.Client.SendBufferSize = _options.SendBufferSize;
|
||||
_client.Connect(_options.Host, _options.Port);
|
||||
|
||||
_connectionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Send HELLO frame
|
||||
var helloFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
await SendFrameInternalAsync(helloFrame, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Connected to UDP gateway at {Host}:{Port} as {ServiceName}/{Version}",
|
||||
_options.Host,
|
||||
_options.Port,
|
||||
instance.ServiceName,
|
||||
instance.Version);
|
||||
|
||||
// Start receiving frames
|
||||
_receiveTask = Task.Run(() => ReceiveLoopAsync(_clientCts.Token), CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _client!.ReceiveAsync(cancellationToken);
|
||||
var data = result.Buffer;
|
||||
|
||||
if (data.Length < UdpFrameProtocol.GetHeaderSize())
|
||||
{
|
||||
_logger.LogWarning("Received datagram too small ({Size} bytes)", data.Length);
|
||||
continue;
|
||||
}
|
||||
|
||||
var frame = UdpFrameProtocol.ParseFrame(data);
|
||||
await ProcessFrameAsync(frame, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "UDP socket error in receive loop");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in receive loop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessFrameAsync(Frame frame, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (frame.Type)
|
||||
{
|
||||
case FrameType.Request:
|
||||
await HandleRequestFrameAsync(frame, cancellationToken);
|
||||
break;
|
||||
|
||||
case FrameType.Cancel:
|
||||
HandleCancelFrame(frame);
|
||||
break;
|
||||
|
||||
case FrameType.Response:
|
||||
if (frame.CorrelationId is not null &&
|
||||
Guid.TryParse(frame.CorrelationId, out var correlationId))
|
||||
{
|
||||
if (_pendingRequests.TryRemove(correlationId, out var tcs))
|
||||
{
|
||||
tcs.TrySetResult(frame);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case FrameType.RequestStreamData:
|
||||
case FrameType.ResponseStreamData:
|
||||
_logger.LogWarning(
|
||||
"UDP transport does not support streaming. Frame type {Type} ignored.",
|
||||
frame.Type);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Unexpected frame type {FrameType}", frame.Type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestFrameAsync(Frame frame, CancellationToken cancellationToken)
|
||||
{
|
||||
if (OnRequestReceived is null)
|
||||
{
|
||||
_logger.LogWarning("No request handler registered");
|
||||
return;
|
||||
}
|
||||
|
||||
var correlationId = frame.CorrelationId ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
using var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_inflightHandlers[correlationId] = handlerCts;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await OnRequestReceived(frame, handlerCts.Token);
|
||||
var responseFrame = response with { CorrelationId = correlationId };
|
||||
|
||||
if (!handlerCts.Token.IsCancellationRequested)
|
||||
{
|
||||
await SendFrameInternalAsync(responseFrame, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Request {CorrelationId} was cancelled", correlationId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling request {CorrelationId}", correlationId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inflightHandlers.TryRemove(correlationId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleCancelFrame(Frame frame)
|
||||
{
|
||||
if (frame.CorrelationId is null) return;
|
||||
|
||||
_logger.LogDebug("Received CANCEL for {CorrelationId}", frame.CorrelationId);
|
||||
|
||||
if (_inflightHandlers.TryGetValue(frame.CorrelationId, out var cts))
|
||||
{
|
||||
try
|
||||
{
|
||||
cts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already completed
|
||||
}
|
||||
}
|
||||
|
||||
if (Guid.TryParse(frame.CorrelationId, out var guid))
|
||||
{
|
||||
if (_pendingRequests.TryRemove(guid, out var tcs))
|
||||
{
|
||||
tcs.TrySetCanceled();
|
||||
}
|
||||
OnCancelReceived?.Invoke(guid, null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendFrameInternalAsync(Frame frame, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var data = UdpFrameProtocol.SerializeFrame(frame);
|
||||
|
||||
if (data.Length > _options.MaxDatagramSize)
|
||||
{
|
||||
throw new PayloadTooLargeException(data.Length, _options.MaxDatagramSize);
|
||||
}
|
||||
|
||||
await _client!.SendAsync(data, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Frame> SendRequestAsync(
|
||||
ConnectionState connection,
|
||||
Frame requestFrame,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var correlationId = requestFrame.CorrelationId is not null &&
|
||||
Guid.TryParse(requestFrame.CorrelationId, out var parsed)
|
||||
? parsed
|
||||
: Guid.NewGuid();
|
||||
|
||||
var framedRequest = requestFrame with { CorrelationId = correlationId.ToString("N") };
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
var tcs = new TaskCompletionSource<Frame>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var registration = timeoutCts.Token.Register(() =>
|
||||
{
|
||||
if (_pendingRequests.TryRemove(correlationId, out var pendingTcs))
|
||||
{
|
||||
pendingTcs.TrySetCanceled(timeoutCts.Token);
|
||||
}
|
||||
});
|
||||
|
||||
_pendingRequests[correlationId] = tcs;
|
||||
|
||||
try
|
||||
{
|
||||
await SendFrameInternalAsync(framedRequest, timeoutCts.Token);
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"Request {correlationId} timed out after {timeout}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await registration.DisposeAsync();
|
||||
_pendingRequests.TryRemove(correlationId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendCancelAsync(
|
||||
ConnectionState connection,
|
||||
Guid correlationId,
|
||||
string? reason = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var cancelFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Cancel,
|
||||
CorrelationId = correlationId.ToString("N"),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
// Best effort - UDP may not deliver
|
||||
await SendFrameInternalAsync(cancelFrame, CancellationToken.None);
|
||||
_logger.LogDebug("Sent CANCEL for {CorrelationId} (best effort)", correlationId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SendStreamingAsync(
|
||||
ConnectionState connection,
|
||||
Frame requestHeader,
|
||||
Stream requestBody,
|
||||
Func<Stream, Task> readResponseBody,
|
||||
PayloadLimits limits,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"UDP transport does not support streaming. Use TCP or TLS transport.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a heartbeat.
|
||||
/// </summary>
|
||||
public async Task SendHeartbeatAsync(HeartbeatPayload heartbeat, CancellationToken cancellationToken)
|
||||
{
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat,
|
||||
CorrelationId = null,
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
await SendFrameInternalAsync(frame, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels all in-flight handlers.
|
||||
/// </summary>
|
||||
public void CancelAllInflight(string reason)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var cts in _inflightHandlers.Values)
|
||||
{
|
||||
try
|
||||
{
|
||||
cts.Cancel();
|
||||
count++;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already completed
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
_logger.LogInformation("Cancelled {Count} in-flight handlers: {Reason}", count, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects from the gateway.
|
||||
/// </summary>
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
CancelAllInflight("Shutdown");
|
||||
|
||||
// Cancel all pending requests
|
||||
foreach (var kvp in _pendingRequests)
|
||||
{
|
||||
if (_pendingRequests.TryRemove(kvp.Key, out var tcs))
|
||||
{
|
||||
tcs.TrySetCanceled();
|
||||
}
|
||||
}
|
||||
|
||||
await _clientCts.CancelAsync();
|
||||
|
||||
if (_receiveTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _receiveTask;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
_client?.Dispose();
|
||||
_logger.LogInformation("Disconnected from UDP gateway");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await DisconnectAsync();
|
||||
|
||||
_clientCts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Router.Transport.Udp;
|
||||
|
||||
/// <summary>
|
||||
/// Options for UDP transport configuration.
|
||||
/// </summary>
|
||||
public sealed class UdpTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the bind address for the server.
|
||||
/// </summary>
|
||||
public IPAddress BindAddress { get; set; } = IPAddress.Any;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the port to listen on/connect to.
|
||||
/// </summary>
|
||||
public int Port { get; set; } = 5102;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the host to connect to (client only).
|
||||
/// </summary>
|
||||
public string? Host { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum datagram size in bytes.
|
||||
/// Conservative default well under typical MTU of 1500 bytes.
|
||||
/// </summary>
|
||||
public int MaxDatagramSize { get; set; } = 8192;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default timeout for requests.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to allow broadcast.
|
||||
/// </summary>
|
||||
public bool AllowBroadcast { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the receive buffer size.
|
||||
/// </summary>
|
||||
public int ReceiveBufferSize { get; set; } = 64 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the send buffer size.
|
||||
/// </summary>
|
||||
public int SendBufferSize { get; set; } = 64 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.Udp;
|
||||
|
||||
/// <summary>
|
||||
/// UDP transport server implementation for the gateway.
|
||||
/// UDP transport is stateless - connections are logical based on source endpoint.
|
||||
/// </summary>
|
||||
public sealed class UdpTransportServer : ITransportServer, IAsyncDisposable
|
||||
{
|
||||
private readonly UdpTransportOptions _options;
|
||||
private readonly ILogger<UdpTransportServer> _logger;
|
||||
private readonly ConcurrentDictionary<IPEndPoint, string> _endpointToConnectionId = new();
|
||||
private readonly ConcurrentDictionary<string, (IPEndPoint Endpoint, ConnectionState State)> _connections = new();
|
||||
private UdpClient? _listener;
|
||||
private CancellationTokenSource? _serverCts;
|
||||
private Task? _receiveTask;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a connection is established (on first HELLO).
|
||||
/// </summary>
|
||||
public event Action<string, ConnectionState>? OnConnection;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a connection is lost.
|
||||
/// </summary>
|
||||
public event Action<string>? OnDisconnection;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a frame is received.
|
||||
/// </summary>
|
||||
public event Action<string, Frame>? OnFrame;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UdpTransportServer"/> class.
|
||||
/// </summary>
|
||||
public UdpTransportServer(
|
||||
IOptions<UdpTransportOptions> options,
|
||||
ILogger<UdpTransportServer> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_serverCts = new CancellationTokenSource();
|
||||
|
||||
var endpoint = new IPEndPoint(_options.BindAddress, _options.Port);
|
||||
_listener = new UdpClient(endpoint)
|
||||
{
|
||||
EnableBroadcast = _options.AllowBroadcast
|
||||
};
|
||||
|
||||
// Configure socket buffers
|
||||
_listener.Client.ReceiveBufferSize = _options.ReceiveBufferSize;
|
||||
_listener.Client.SendBufferSize = _options.SendBufferSize;
|
||||
|
||||
_logger.LogInformation(
|
||||
"UDP transport server listening on {Address}:{Port}",
|
||||
_options.BindAddress,
|
||||
_options.Port);
|
||||
|
||||
_receiveTask = ReceiveLoopAsync(_serverCts.Token);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _listener!.ReceiveAsync(cancellationToken);
|
||||
var remoteEndpoint = result.RemoteEndPoint;
|
||||
var data = result.Buffer;
|
||||
|
||||
if (data.Length < UdpFrameProtocol.GetHeaderSize())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Received datagram too small ({Size} bytes) from {Endpoint}",
|
||||
data.Length,
|
||||
remoteEndpoint);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse frame
|
||||
var frame = UdpFrameProtocol.ParseFrame(data);
|
||||
|
||||
// Get or create connection ID for this endpoint
|
||||
var connectionId = _endpointToConnectionId.GetOrAdd(
|
||||
remoteEndpoint,
|
||||
_ => $"udp-{remoteEndpoint.Address}-{remoteEndpoint.Port}-{Guid.NewGuid():N}"[..32]);
|
||||
|
||||
// Handle HELLO specially to register connection
|
||||
if (frame.Type == FrameType.Hello && !_connections.ContainsKey(connectionId))
|
||||
{
|
||||
var state = new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = connectionId,
|
||||
ServiceName = "unknown",
|
||||
Version = "1.0.0",
|
||||
Region = "default"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = TransportType.Udp
|
||||
};
|
||||
|
||||
_connections[connectionId] = (remoteEndpoint, state);
|
||||
_logger.LogInformation(
|
||||
"UDP connection established: {ConnectionId} from {Endpoint}",
|
||||
connectionId,
|
||||
remoteEndpoint);
|
||||
OnConnection?.Invoke(connectionId, state);
|
||||
}
|
||||
|
||||
// Update heartbeat timestamp on HEARTBEAT frames
|
||||
if (frame.Type == FrameType.Heartbeat &&
|
||||
_connections.TryGetValue(connectionId, out var conn))
|
||||
{
|
||||
conn.State.LastHeartbeatUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
OnFrame?.Invoke(connectionId, frame);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on shutdown
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Listener disposed
|
||||
break;
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "UDP socket error");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error receiving UDP datagram");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a frame to a connection.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">The connection ID.</param>
|
||||
/// <param name="frame">The frame to send.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task SendFrameAsync(
|
||||
string connectionId,
|
||||
Frame frame,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!_connections.TryGetValue(connectionId, out var conn))
|
||||
{
|
||||
throw new InvalidOperationException($"Connection {connectionId} not found");
|
||||
}
|
||||
|
||||
var data = UdpFrameProtocol.SerializeFrame(frame);
|
||||
|
||||
if (data.Length > _options.MaxDatagramSize)
|
||||
{
|
||||
throw new PayloadTooLargeException(data.Length, _options.MaxDatagramSize);
|
||||
}
|
||||
|
||||
await _listener!.SendAsync(data, conn.Endpoint, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection state by ID.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">The connection ID.</param>
|
||||
/// <returns>The connection state, or null if not found.</returns>
|
||||
public ConnectionState? GetConnectionState(string connectionId)
|
||||
{
|
||||
return _connections.TryGetValue(connectionId, out var conn) ? conn.State : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active connections.
|
||||
/// </summary>
|
||||
public IEnumerable<ConnectionState> GetConnections() =>
|
||||
_connections.Values.Select(c => c.State);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active connections.
|
||||
/// </summary>
|
||||
public int ConnectionCount => _connections.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Removes a connection (for cleanup purposes).
|
||||
/// </summary>
|
||||
/// <param name="connectionId">The connection ID.</param>
|
||||
public void RemoveConnection(string connectionId)
|
||||
{
|
||||
if (_connections.TryRemove(connectionId, out var conn))
|
||||
{
|
||||
_endpointToConnectionId.TryRemove(conn.Endpoint, out _);
|
||||
_logger.LogInformation("UDP connection removed: {ConnectionId}", connectionId);
|
||||
OnDisconnection?.Invoke(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping UDP transport server");
|
||||
|
||||
if (_serverCts is not null)
|
||||
{
|
||||
await _serverCts.CancelAsync();
|
||||
}
|
||||
|
||||
_listener?.Close();
|
||||
|
||||
if (_receiveTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _receiveTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
_connections.Clear();
|
||||
_endpointToConnectionId.Clear();
|
||||
|
||||
_logger.LogInformation("UDP transport server stopped");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await StopAsync(CancellationToken.None);
|
||||
|
||||
_listener?.Dispose();
|
||||
_serverCts?.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user