feat: implement authenticated NAT mediator (#11)
quality-gate / quality (push) Successful in 59s
quality-gate / quality (push) Successful in 59s
Closes #11
This commit is contained in:
@@ -1,161 +1,136 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using LiteNetLib;
|
||||
using LiteNetLib.Layers;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the cancellable UDP socket used by the future NAT mediator.
|
||||
/// </summary>
|
||||
internal sealed partial class UdpMediatorService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<UdpMediatorService> _logger;
|
||||
private readonly UdpMediatorOptions _options;
|
||||
private readonly IEphemeralRendezvousStore _store;
|
||||
private readonly ISessionCapabilityService _capabilities;
|
||||
private UdpClient? _udpClient;
|
||||
private readonly NatMediationProcessor _processor;
|
||||
private LiteNetManager? _manager;
|
||||
private LiteNetIntroductionSink? _introductionSink;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new UDP mediator service.
|
||||
/// </summary>
|
||||
public UdpMediatorService(
|
||||
IOptions<UdpMediatorOptions> options,
|
||||
ILogger<UdpMediatorService> logger,
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities)
|
||||
NatMediationProcessor processor)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_store = store;
|
||||
_capabilities = capabilities;
|
||||
_processor = processor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bound endpoint after startup completes.
|
||||
/// </summary>
|
||||
public IPEndPoint? LocalEndpoint { get; private set; }
|
||||
public IPEndPoint? LocalIpv6Endpoint { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_udpClient is not null)
|
||||
if (_manager is not null)
|
||||
{
|
||||
throw new InvalidOperationException("The UDP mediator is already running.");
|
||||
}
|
||||
|
||||
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
|
||||
UdpClient udpClient = new(new IPEndPoint(listenAddress, _options.Port));
|
||||
_udpClient = udpClient;
|
||||
IPEndPoint localEndpoint =
|
||||
(IPEndPoint?)udpClient.Client.LocalEndPoint
|
||||
?? throw new InvalidOperationException("The UDP socket did not expose its bound endpoint.");
|
||||
LocalEndpoint = localEndpoint;
|
||||
if (listenAddress.AddressFamily != AddressFamily.InterNetwork)
|
||||
{
|
||||
throw new InvalidOperationException("The required UDP listen address must be IPv4.");
|
||||
}
|
||||
|
||||
LogMediatorListening(_logger, localEndpoint.Address, localEndpoint.Port);
|
||||
IPAddress? ipv6ListenAddress = string.IsNullOrWhiteSpace(_options.Ipv6ListenAddress)
|
||||
? null
|
||||
: IPAddress.Parse(_options.Ipv6ListenAddress);
|
||||
if (ipv6ListenAddress is not null
|
||||
&& ipv6ListenAddress.AddressFamily != AddressFamily.InterNetworkV6)
|
||||
{
|
||||
throw new InvalidOperationException("The optional UDP IPv6 listen address must be IPv6.");
|
||||
}
|
||||
|
||||
EventBasedLiteNetListener listener = new();
|
||||
RendezvousPacketLayer packetLayer = new(_processor);
|
||||
LiteNetManager manager = new(listener, packetLayer)
|
||||
{
|
||||
NatPunchEnabled = true,
|
||||
IPv6Enabled = ipv6ListenAddress is not null,
|
||||
UnsyncedEvents = true,
|
||||
MaxPacketPerManualReceive = _options.MaxDatagramsPerPoll,
|
||||
};
|
||||
manager.NatPunchModule.UnsyncedEvents = true;
|
||||
_introductionSink = new(manager.NatPunchModule);
|
||||
packetLayer.Attach(_introductionSink);
|
||||
|
||||
if (!manager.StartInManualMode(
|
||||
listenAddress,
|
||||
ipv6ListenAddress ?? IPAddress.IPv6Any,
|
||||
_options.Port))
|
||||
{
|
||||
_introductionSink = null;
|
||||
manager.Stop();
|
||||
throw new InvalidOperationException("The UDP mediator could not bind its LiteNetLib socket.");
|
||||
}
|
||||
|
||||
_manager = manager;
|
||||
LocalEndpoint = new(listenAddress, manager.LocalPort);
|
||||
LocalIpv6Endpoint = ipv6ListenAddress is null
|
||||
? null
|
||||
: new(ipv6ListenAddress, manager.LocalPort);
|
||||
LogMediatorListening(_logger, listenAddress, manager.LocalPort);
|
||||
return base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
||||
_udpClient?.Dispose();
|
||||
_udpClient = null;
|
||||
LocalEndpoint = null;
|
||||
StopManager();
|
||||
LogMediatorStopped(_logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Dispose()
|
||||
{
|
||||
_udpClient?.Dispose();
|
||||
_udpClient = null;
|
||||
LocalEndpoint = null;
|
||||
StopManager();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
UdpClient udpClient = _udpClient
|
||||
LiteNetManager manager = _manager
|
||||
?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
|
||||
|
||||
long previous = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
UdpReceiveResult received = await udpClient
|
||||
.ReceiveAsync(stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
ProcessDatagram(received.Buffer, received.RemoteEndPoint, stoppingToken);
|
||||
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
|
||||
manager.PollEvents();
|
||||
manager.NatPunchModule.PollEvents();
|
||||
long current = Stopwatch.GetTimestamp();
|
||||
manager.ManualUpdate((float)Stopwatch.GetElapsedTime(previous, current).TotalMilliseconds);
|
||||
previous = current;
|
||||
await Task.Delay(_options.PollIntervalMilliseconds, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Expected during normal shutdown.
|
||||
}
|
||||
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Disposing the socket is the fallback that releases a blocked receive.
|
||||
}
|
||||
finally
|
||||
{
|
||||
LocalEndpoint = null;
|
||||
LocalIpv6Endpoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
internal UdpPresenceProcessingResult ProcessDatagram(
|
||||
ReadOnlySpan<byte> encoded,
|
||||
IPEndPoint observedSource,
|
||||
CancellationToken cancellationToken = default)
|
||||
private void StopManager()
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observedSource);
|
||||
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||
|| datagram is null
|
||||
|| !_capabilities.TryFingerprint(datagram.Capability, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return UdpPresenceProcessingResult.Dropped;
|
||||
}
|
||||
|
||||
if (datagram.MessageType != UdpPresenceMessageType.HostPresence)
|
||||
{
|
||||
return UdpPresenceProcessingResult.ClientPresenceDeferred;
|
||||
}
|
||||
|
||||
AddressFamilyKind publicFamily = observedSource.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||
_ => 0,
|
||||
};
|
||||
if (publicFamily == 0)
|
||||
{
|
||||
return UdpPresenceProcessingResult.Dropped;
|
||||
}
|
||||
|
||||
ObservedEndpoint publicEndpoint = new(
|
||||
publicFamily,
|
||||
observedSource.Address.ToString(),
|
||||
observedSource.Port);
|
||||
ObservedEndpoint localEndpoint = new(
|
||||
datagram.AddressFamily,
|
||||
datagram.LocalAddress,
|
||||
datagram.LocalPort);
|
||||
StoreResult<StoredListing> bound = _store.BindHostPresence(new(
|
||||
datagram.MediationHandle,
|
||||
fingerprint,
|
||||
publicEndpoint,
|
||||
localEndpoint), cancellationToken);
|
||||
return bound.Succeeded
|
||||
? UdpPresenceProcessingResult.HostPresenceAccepted
|
||||
: UdpPresenceProcessingResult.HostPresenceRejected;
|
||||
LiteNetManager? manager = Interlocked.Exchange(ref _manager, null);
|
||||
_introductionSink = null;
|
||||
LocalEndpoint = null;
|
||||
LocalIpv6Endpoint = null;
|
||||
manager?.Stop();
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
@@ -172,12 +147,62 @@ internal sealed partial class UdpMediatorService : BackgroundService
|
||||
Level = LogLevel.Information,
|
||||
Message = "UDP mediator stopped")]
|
||||
private static partial void LogMediatorStopped(ILogger logger);
|
||||
}
|
||||
|
||||
internal enum UdpPresenceProcessingResult
|
||||
{
|
||||
Dropped = 0,
|
||||
HostPresenceAccepted = 1,
|
||||
HostPresenceRejected = 2,
|
||||
ClientPresenceDeferred = 3,
|
||||
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
|
||||
{
|
||||
public void Introduce(NatIntroductionPlan plan) => module.NatIntroduce(
|
||||
plan.HostLocal,
|
||||
plan.HostPublic,
|
||||
plan.ClientLocal,
|
||||
plan.ClientPublic,
|
||||
plan.ConnectionTicket);
|
||||
}
|
||||
|
||||
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
|
||||
{
|
||||
private INatIntroductionSink? _sink;
|
||||
|
||||
public void Attach(INatIntroductionSink sink) => _sink = sink;
|
||||
|
||||
public override void ProcessInboundPacket(
|
||||
ref IPEndPoint endPoint,
|
||||
ref byte[] data,
|
||||
ref int length)
|
||||
{
|
||||
bool isFrozenEnvelope = length >= 2
|
||||
&& data[0] == RendezvousUdpCodec.MagicFirst
|
||||
&& data[1] == RendezvousUdpCodec.MagicSecond;
|
||||
INatIntroductionSink? sink = _sink;
|
||||
if (isFrozenEnvelope)
|
||||
{
|
||||
if (sink is not null)
|
||||
{
|
||||
_ = processor.ProcessDatagram(data.AsSpan(0, length), endPoint, sink);
|
||||
}
|
||||
}
|
||||
else if (sink is not null
|
||||
&& LiteNetNatRequestCodec.TryDecode(
|
||||
data.AsSpan(0, length),
|
||||
out IPEndPoint? claimedLocalEndpoint,
|
||||
out string? token)
|
||||
&& claimedLocalEndpoint is not null
|
||||
&& token is not null)
|
||||
{
|
||||
_ = processor.ProcessRequest(claimedLocalEndpoint, endPoint, token, sink);
|
||||
}
|
||||
|
||||
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
|
||||
Drop(ref length);
|
||||
}
|
||||
|
||||
public override void ProcessOutBoundPacket(
|
||||
ref IPEndPoint endPoint,
|
||||
ref byte[] data,
|
||||
ref int offset,
|
||||
ref int length)
|
||||
{
|
||||
}
|
||||
|
||||
private static void Drop(ref int length) => length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user