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:
@@ -42,6 +42,21 @@ if (!registered.IsSuccess || registered.Value is null)
|
||||
Load `publisherCredential` from the game's deployment secret boundary; never
|
||||
embed it in a client build or source control. A successful registration returns a
|
||||
`PublishedSession` containing the lease and host-presence capabilities.
|
||||
Send a periodic presence request from the host's gameplay `NetManager` using the
|
||||
server-controlled refresh interval and the fixed-size native token:
|
||||
|
||||
```csharp
|
||||
string presenceToken = NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.HostPresence,
|
||||
session.HostPresenceHandle,
|
||||
session.HostPresenceCapability);
|
||||
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
|
||||
```
|
||||
|
||||
The same codec creates `Host` tokens for host-polled invitations and `Client`
|
||||
tokens for a created join attempt. Always send them from the same LiteNetLib
|
||||
socket that will carry the direct game connection; the mediator ignores any
|
||||
caller-supplied public endpoint.
|
||||
|
||||
Lease renewal is explicit and caller-controlled:
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ public static class ContractLimits
|
||||
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
|
||||
public const int UdpCapabilityMaxCharacters = 192;
|
||||
public const int ConnectionTicketMaxCharacters = 192;
|
||||
public const int DerivedCredentialCharacters = 43;
|
||||
public const int NatPunchRequestTokenCharacters = 192;
|
||||
public const int LiteNetLibNatTokenMaxCharacters = 256;
|
||||
public const int SessionCapacityMaxPlayers = 10_000;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
namespace FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
public enum NatPunchPeerRole
|
||||
{
|
||||
HostPresence = 1,
|
||||
Host = 2,
|
||||
Client = 3,
|
||||
}
|
||||
|
||||
public sealed class NatPunchRequestToken
|
||||
{
|
||||
public NatPunchPeerRole Role { get; set; }
|
||||
public MediationHandle MediationHandle { get; set; }
|
||||
public string Capability { get; set; } = string.Empty;
|
||||
|
||||
public override string ToString() => "[NatPunchRequestToken: capability redacted]";
|
||||
}
|
||||
|
||||
public static class NatPunchRequestTokenCodec
|
||||
{
|
||||
public const int EncodedLength = ContractLimits.NatPunchRequestTokenCharacters;
|
||||
|
||||
private const string VersionPrefix = "rv1:";
|
||||
private const int HandleLength = 32;
|
||||
private const int CapabilityLength = ContractLimits.DerivedCredentialCharacters;
|
||||
private const char Separator = ':';
|
||||
private const char Padding = '.';
|
||||
|
||||
public static string Encode(
|
||||
NatPunchPeerRole role,
|
||||
MediationHandle mediationHandle,
|
||||
string capability)
|
||||
{
|
||||
if (!TryGetRoleCode(role, out char roleCode)
|
||||
|| mediationHandle.Value == Guid.Empty
|
||||
|| capability is null
|
||||
|| capability.Length != CapabilityLength
|
||||
|| !ContractValidation.IsCapabilityValid(capability))
|
||||
{
|
||||
throw new ArgumentException("The NAT punch request token fields are invalid.");
|
||||
}
|
||||
|
||||
string payload = string.Concat(
|
||||
VersionPrefix,
|
||||
roleCode,
|
||||
Separator,
|
||||
mediationHandle.Value.ToString("N"),
|
||||
Separator,
|
||||
capability);
|
||||
return payload.PadRight(EncodedLength, Padding);
|
||||
}
|
||||
|
||||
public static bool TryDecode(string? encoded, out NatPunchRequestToken? token)
|
||||
{
|
||||
token = null;
|
||||
if (encoded is null
|
||||
|| encoded.Length != EncodedLength
|
||||
|| !encoded.StartsWith(VersionPrefix, StringComparison.Ordinal)
|
||||
|| !TryParseRole(encoded[VersionPrefix.Length], out NatPunchPeerRole role))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int roleSeparator = VersionPrefix.Length + 1;
|
||||
int handleOffset = roleSeparator + 1;
|
||||
int capabilitySeparator = handleOffset + HandleLength;
|
||||
int capabilityOffset = capabilitySeparator + 1;
|
||||
int paddingOffset = capabilityOffset + CapabilityLength;
|
||||
string handleText = encoded.Substring(handleOffset, HandleLength);
|
||||
if (encoded[roleSeparator] != Separator
|
||||
|| encoded[capabilitySeparator] != Separator
|
||||
|| !Guid.TryParseExact(handleText, "N", out Guid handle)
|
||||
|| handle == Guid.Empty
|
||||
|| !string.Equals(handleText, handle.ToString("N"), StringComparison.Ordinal)
|
||||
|| !ContainsOnlyPadding(encoded, paddingOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string capability = encoded.Substring(capabilityOffset, CapabilityLength);
|
||||
if (!ContractValidation.IsCapabilityValid(capability))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
token = new NatPunchRequestToken
|
||||
{
|
||||
Role = role,
|
||||
MediationHandle = new MediationHandle(handle),
|
||||
Capability = capability,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetRoleCode(NatPunchPeerRole role, out char code)
|
||||
{
|
||||
code = role switch
|
||||
{
|
||||
NatPunchPeerRole.HostPresence => 'p',
|
||||
NatPunchPeerRole.Host => 'h',
|
||||
NatPunchPeerRole.Client => 'c',
|
||||
_ => default,
|
||||
};
|
||||
return code != default;
|
||||
}
|
||||
|
||||
private static bool TryParseRole(char code, out NatPunchPeerRole role)
|
||||
{
|
||||
role = code switch
|
||||
{
|
||||
'p' => NatPunchPeerRole.HostPresence,
|
||||
'h' => NatPunchPeerRole.Host,
|
||||
'c' => NatPunchPeerRole.Client,
|
||||
_ => default,
|
||||
};
|
||||
return role != default;
|
||||
}
|
||||
|
||||
private static bool ContainsOnlyPadding(string value, int offset)
|
||||
{
|
||||
for (int index = offset; index < value.Length; index++)
|
||||
{
|
||||
if (value[index] != Padding)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,9 @@ internal sealed class JoinAttemptService(
|
||||
ContractValidation.IsCapabilityValid(hostCapability)
|
||||
&& ContractValidation.IsCapabilityValid(clientCapability)
|
||||
&& ContractValidation.IsConnectionTicketValid(ticket)
|
||||
&& hostCapability.Length == ContractLimits.DerivedCredentialCharacters
|
||||
&& clientCapability.Length == ContractLimits.DerivedCredentialCharacters
|
||||
&& ticket.Length == ContractLimits.DerivedCredentialCharacters
|
||||
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
|
||||
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
|
||||
|
||||
|
||||
@@ -133,12 +133,19 @@ builder.Services
|
||||
.BindConfiguration(UdpMediatorOptions.SectionName)
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(
|
||||
options => IPAddress.TryParse(options.ListenAddress, out _),
|
||||
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
|
||||
options => IPAddress.TryParse(options.ListenAddress, out IPAddress? address)
|
||||
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork,
|
||||
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IPv4 address.")
|
||||
.Validate(
|
||||
options => string.IsNullOrWhiteSpace(options.Ipv6ListenAddress)
|
||||
|| (IPAddress.TryParse(options.Ipv6ListenAddress, out IPAddress? address)
|
||||
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6),
|
||||
$"{UdpMediatorOptions.SectionName}:Ipv6ListenAddress must be an IPv6 address when configured.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton<UdpMediatorService>();
|
||||
if (!isOpenApiGeneration)
|
||||
{
|
||||
builder.Services.AddSingleton<NatMediationProcessor>();
|
||||
builder.Services.AddHostedService(static services =>
|
||||
services.GetRequiredService<UdpMediatorService>());
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousStore
|
||||
{
|
||||
private static readonly TimeSpan UdpMaintenanceInterval = TimeSpan.FromSeconds(1);
|
||||
private readonly object _gate = new();
|
||||
private readonly EphemeralStoreOptions _options;
|
||||
private readonly IMonotonicClock _monotonicClock;
|
||||
@@ -19,6 +20,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
|
||||
private TimeSpan? _drainDeadline;
|
||||
private TimeSpan _nextUdpMaintenance;
|
||||
private long _maintenanceSweepCount;
|
||||
private bool _available = true;
|
||||
|
||||
public InMemoryEphemeralRendezvousStore(
|
||||
@@ -38,6 +41,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
|
||||
public Guid InstanceId { get; }
|
||||
internal long MaintenanceSweepCount => Interlocked.Read(ref _maintenanceSweepCount);
|
||||
|
||||
public bool IsAvailable
|
||||
{
|
||||
@@ -270,6 +274,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|
||||
if (!_presenceHandles.TryGetValue(command.Handle, out SessionListingId listingId)
|
||||
|| !_listings.TryGetValue(listingId, out ListingEntry? entry)
|
||||
|| entry.LeaseDeadline <= now
|
||||
|| entry.Definition.HostPresenceFingerprint != command.CapabilityFingerprint)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
@@ -285,7 +290,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
command.LocalEndpoint,
|
||||
now + _options.PresenceLifetime);
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
}, cancellationToken);
|
||||
}, cancellationToken, eagerCleanup: false);
|
||||
|
||||
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
|
||||
VisibleListingQuery query,
|
||||
@@ -446,13 +451,18 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (attempt.IntroductionConsumed)
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
RemoveAttempt(command.AttemptId);
|
||||
return new(StoreResultCode.Success, true);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
|
||||
BindAttemptEndpointCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ =>
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(now =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (command.Handle.Value == Guid.Empty
|
||||
@@ -469,7 +479,13 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
|
||||
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|
||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
|
||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
||||
|| attempt.Deadline <= now)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (!HasFreshHostPresence(attempt, now))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
@@ -503,7 +519,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
|
||||
return new(StoreResultCode.Success, Snapshot(attempt));
|
||||
}, cancellationToken);
|
||||
}, cancellationToken, eagerCleanup: false);
|
||||
|
||||
public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
|
||||
MediationHandle handle,
|
||||
@@ -515,7 +531,13 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
|
||||
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|
||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
|
||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
||||
|| attempt.Deadline <= now)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (!HasFreshHostPresence(attempt, now))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
@@ -540,7 +562,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
Snapshot(attempt),
|
||||
attempt.HostEndpoint,
|
||||
attempt.ClientEndpoint));
|
||||
}, cancellationToken);
|
||||
}, cancellationToken, eagerCleanup: false);
|
||||
|
||||
public StoreResult<bool> ConsumeConnectionTicket(
|
||||
ConsumeConnectionTicketCommand command,
|
||||
@@ -687,18 +709,38 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
}
|
||||
|
||||
private StoreResult<T> Atomic<T>(Func<TimeSpan, StoreResult<T>> operation, CancellationToken cancellationToken)
|
||||
private StoreResult<T> Atomic<T>(
|
||||
Func<TimeSpan, StoreResult<T>> operation,
|
||||
CancellationToken cancellationToken,
|
||||
bool eagerCleanup = true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
lock (_gate)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
TimeSpan now = _monotonicClock.Elapsed;
|
||||
Cleanup(now);
|
||||
// Authenticated UDP duplicates need O(1) store work. Their operations
|
||||
// check exact resource deadlines and amortize physical expiry removal.
|
||||
bool drainExpired = _drainDeadline is TimeSpan drainDeadline
|
||||
&& now >= drainDeadline;
|
||||
if (drainExpired || eagerCleanup || now >= _nextUdpMaintenance)
|
||||
{
|
||||
Cleanup(now);
|
||||
_nextUdpMaintenance = now + UdpMaintenanceInterval;
|
||||
}
|
||||
|
||||
return operation(now);
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasFreshHostPresence(AttemptEntry attempt, TimeSpan now) =>
|
||||
_listings.TryGetValue(attempt.Command.ListingId, out ListingEntry? listing)
|
||||
&& listing.LeaseDeadline > now
|
||||
&& _presence.TryGetValue(
|
||||
listing.Definition.HostPresenceHandle,
|
||||
out PresenceEntry? presence)
|
||||
&& presence.Deadline > now;
|
||||
|
||||
private StoreResult<T>? CheckNewWorkAdmission<T>(string subject)
|
||||
{
|
||||
if (!_available)
|
||||
@@ -718,6 +760,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|
||||
private void Cleanup(TimeSpan now)
|
||||
{
|
||||
_maintenanceSweepCount++;
|
||||
if (_drainDeadline is TimeSpan drainDeadline && now >= drainDeadline)
|
||||
{
|
||||
ClearActiveState();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||
|
||||
internal static class LiteNetNatRequestCodec
|
||||
{
|
||||
private const byte NatMessageProperty = 17;
|
||||
private const int TypeIdentifierLength = 8;
|
||||
private const int TokenLengthPrefix = NatPunchRequestTokenCodec.EncodedLength + 1;
|
||||
// LiteNetLib 2.1.4's private NatIntroduceRequest type ID. The native socket
|
||||
// integration test deliberately fails if a package upgrade changes this wire value.
|
||||
private static ReadOnlySpan<byte> RequestTypeIdentifier =>
|
||||
[0x88, 0xbe, 0x10, 0x26, 0xbf, 0xb1, 0x66, 0x9c];
|
||||
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> datagram,
|
||||
out IPEndPoint? claimedLocalEndpoint,
|
||||
out string? token)
|
||||
{
|
||||
claimedLocalEndpoint = null;
|
||||
token = null;
|
||||
if (datagram.Length < 1 + TypeIdentifierLength + 1 + 4 + 2 + 2
|
||||
+ NatPunchRequestTokenCodec.EncodedLength
|
||||
|| datagram[0] != NatMessageProperty
|
||||
|| !datagram.Slice(1, TypeIdentifierLength).SequenceEqual(RequestTypeIdentifier))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int offset = 1 + TypeIdentifierLength;
|
||||
int addressLength = datagram[offset++] switch
|
||||
{
|
||||
0 => 4,
|
||||
1 => 16,
|
||||
_ => 0,
|
||||
};
|
||||
int expectedLength = offset + addressLength + 2 + 2
|
||||
+ NatPunchRequestTokenCodec.EncodedLength;
|
||||
if (addressLength == 0 || datagram.Length != expectedLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IPAddress localAddress = new(datagram.Slice(offset, addressLength));
|
||||
offset += addressLength;
|
||||
int localPort = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
|
||||
offset += 2;
|
||||
int encodedTokenLength = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
|
||||
offset += 2;
|
||||
if (localPort == 0 || encodedTokenLength != TokenLengthPrefix)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> tokenBytes = datagram.Slice(
|
||||
offset,
|
||||
NatPunchRequestTokenCodec.EncodedLength);
|
||||
for (int index = 0; index < tokenBytes.Length; index++)
|
||||
{
|
||||
if (tokenBytes[index] > 0x7f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
claimedLocalEndpoint = new(localAddress, localPort);
|
||||
token = Encoding.ASCII.GetString(tokenBytes);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||
|
||||
internal interface INatIntroductionSink
|
||||
{
|
||||
void Introduce(NatIntroductionPlan plan);
|
||||
}
|
||||
|
||||
internal sealed record NatIntroductionPlan(
|
||||
IPEndPoint HostLocal,
|
||||
IPEndPoint HostPublic,
|
||||
IPEndPoint ClientLocal,
|
||||
IPEndPoint ClientPublic,
|
||||
string ConnectionTicket)
|
||||
{
|
||||
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
|
||||
}
|
||||
|
||||
internal enum NatMediationResult
|
||||
{
|
||||
Dropped = 0,
|
||||
HostPresenceAccepted = 1,
|
||||
HostPresenceRejected = 2,
|
||||
WaitingForPeer = 3,
|
||||
Introduced = 4,
|
||||
Duplicate = 5,
|
||||
Rejected = 6,
|
||||
}
|
||||
|
||||
internal sealed class NatMediationProcessor(
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities,
|
||||
JoinAttemptService joinAttempts)
|
||||
{
|
||||
public NatMediationResult ProcessDatagram(
|
||||
ReadOnlySpan<byte> encoded,
|
||||
IPEndPoint observedPublicEndpoint,
|
||||
INatIntroductionSink introductionSink,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||
|| datagram is null
|
||||
|| datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters
|
||||
|| !IPAddress.TryParse(datagram.LocalAddress, out IPAddress? localAddress))
|
||||
{
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
IPEndPoint claimedLocalEndpoint = new(localAddress, datagram.LocalPort);
|
||||
NatPunchPeerRole role = datagram.MessageType == UdpPresenceMessageType.ClientPresence
|
||||
? NatPunchPeerRole.Client
|
||||
: NatPunchPeerRole.HostPresence;
|
||||
bool observedIpv6 = observedPublicEndpoint.AddressFamily == AddressFamily.InterNetworkV6
|
||||
&& !observedPublicEndpoint.Address.IsIPv4MappedToIPv6;
|
||||
if (role == NatPunchPeerRole.Client && observedIpv6)
|
||||
{
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
NatMediationResult result = ProcessRequest(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
|
||||
introductionSink,
|
||||
cancellationToken);
|
||||
if (role != NatPunchPeerRole.HostPresence
|
||||
|| result != NatMediationResult.HostPresenceRejected
|
||||
|| observedIpv6)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return ProcessRequest(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Host,
|
||||
datagram.MediationHandle,
|
||||
datagram.Capability),
|
||||
introductionSink,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public NatMediationResult ProcessRequest(
|
||||
IPEndPoint claimedLocalEndpoint,
|
||||
IPEndPoint observedPublicEndpoint,
|
||||
string token,
|
||||
INatIntroductionSink introductionSink,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(claimedLocalEndpoint);
|
||||
ArgumentNullException.ThrowIfNull(observedPublicEndpoint);
|
||||
ArgumentNullException.ThrowIfNull(introductionSink);
|
||||
|
||||
if (!NatPunchRequestTokenCodec.TryDecode(token, out NatPunchRequestToken? request)
|
||||
|| request is null
|
||||
|| !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint)
|
||||
|| !capabilities.TryFingerprint(request.Capability, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
ObservedEndpoint? localEndpoint = TryCreatePrivateCandidate(
|
||||
claimedLocalEndpoint,
|
||||
publicEndpoint.AddressFamily,
|
||||
out ObservedEndpoint candidate)
|
||||
? candidate
|
||||
: null;
|
||||
|
||||
if (request.Role == NatPunchPeerRole.HostPresence)
|
||||
{
|
||||
StoreResult<StoredListing> presence = store.BindHostPresence(new(
|
||||
request.MediationHandle,
|
||||
fingerprint,
|
||||
publicEndpoint,
|
||||
localEndpoint), cancellationToken);
|
||||
return presence.Succeeded
|
||||
? NatMediationResult.HostPresenceAccepted
|
||||
: NatMediationResult.HostPresenceRejected;
|
||||
}
|
||||
|
||||
AttemptPeerRole role = request.Role switch
|
||||
{
|
||||
NatPunchPeerRole.Host => AttemptPeerRole.Host,
|
||||
NatPunchPeerRole.Client => AttemptPeerRole.Client,
|
||||
_ => default,
|
||||
};
|
||||
if (role == default)
|
||||
{
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
StoreResult<StoredJoinAttempt> bound = store.BindAttemptEndpoint(new(
|
||||
request.MediationHandle,
|
||||
role,
|
||||
fingerprint,
|
||||
publicEndpoint,
|
||||
localEndpoint), cancellationToken);
|
||||
if (!bound.Succeeded || bound.Value is null)
|
||||
{
|
||||
return bound.Code == StoreResultCode.ReplayRejected
|
||||
? NatMediationResult.Rejected
|
||||
: NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
StoredJoinAttempt attempt = bound.Value;
|
||||
if (attempt.IntroductionConsumed)
|
||||
{
|
||||
return NatMediationResult.Duplicate;
|
||||
}
|
||||
|
||||
if (attempt.HostEndpoint is null || attempt.ClientEndpoint is null)
|
||||
{
|
||||
return NatMediationResult.WaitingForPeer;
|
||||
}
|
||||
|
||||
if (attempt.HostEndpoint.PublicEndpoint.AddressFamily
|
||||
!= attempt.ClientEndpoint.PublicEndpoint.AddressFamily)
|
||||
{
|
||||
return NatMediationResult.Rejected;
|
||||
}
|
||||
|
||||
StoreResult<IntroductionEndpoints> consumed = store.ConsumeIntroduction(
|
||||
request.MediationHandle,
|
||||
cancellationToken);
|
||||
if (!consumed.Succeeded || consumed.Value is null)
|
||||
{
|
||||
return consumed.Code == StoreResultCode.ReplayRejected
|
||||
? NatMediationResult.Duplicate
|
||||
: NatMediationResult.Rejected;
|
||||
}
|
||||
|
||||
JoinAttemptServiceResult<ConnectionTicketGrant> ticket = joinAttempts.IssueConnectionTicket(
|
||||
consumed.Value.Attempt);
|
||||
if (!ticket.Succeeded || ticket.Value is null)
|
||||
{
|
||||
return NatMediationResult.Rejected;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value.Ticket));
|
||||
return NatMediationResult.Introduced;
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException
|
||||
or InvalidOperationException
|
||||
or ArgumentException)
|
||||
{
|
||||
return NatMediationResult.Rejected;
|
||||
}
|
||||
}
|
||||
|
||||
private static NatIntroductionPlan CreatePlan(
|
||||
IntroductionEndpoints endpoints,
|
||||
string connectionTicket)
|
||||
{
|
||||
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
|
||||
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
|
||||
bool sameNat = hostPublic.Address.Equals(clientPublic.Address);
|
||||
IPEndPoint hostLocal = sameNat && endpoints.Host.LocalEndpoint is { } hostCandidate
|
||||
? ToIpEndpoint(hostCandidate)
|
||||
: hostPublic;
|
||||
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
|
||||
? ToIpEndpoint(clientCandidate)
|
||||
: clientPublic;
|
||||
return new(hostLocal, hostPublic, clientLocal, clientPublic, connectionTicket);
|
||||
}
|
||||
|
||||
private static bool TryCreateObservedEndpoint(
|
||||
IPEndPoint source,
|
||||
out ObservedEndpoint endpoint)
|
||||
{
|
||||
endpoint = default;
|
||||
if (source.Port is < 1 or > 65_535)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IPAddress address = source.Address.IsIPv4MappedToIPv6
|
||||
? source.Address.MapToIPv4()
|
||||
: source.Address;
|
||||
if (address.Equals(IPAddress.Any)
|
||||
|| address.Equals(IPAddress.IPv6Any)
|
||||
|| address.IsIPv6Multicast
|
||||
|| IsIpv4MulticastOrBroadcast(address)
|
||||
|| (address.AddressFamily == AddressFamily.InterNetworkV6
|
||||
&& !IsGlobalIpv6(address)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AddressFamilyKind family = address.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||
_ => default,
|
||||
};
|
||||
if (family == default)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
endpoint = new(family, address.ToString(), source.Port);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryCreatePrivateCandidate(
|
||||
IPEndPoint source,
|
||||
AddressFamilyKind publicFamily,
|
||||
out ObservedEndpoint endpoint)
|
||||
{
|
||||
endpoint = default;
|
||||
if (source.Port is < 1 or > 65_535)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IPAddress address = source.Address.IsIPv4MappedToIPv6
|
||||
? source.Address.MapToIPv4()
|
||||
: source.Address;
|
||||
AddressFamilyKind family = address.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||
_ => default,
|
||||
};
|
||||
if (family != publicFamily || !IsPrivateUnicast(address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
endpoint = new(family, address.ToString(), source.Port);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsPrivateUnicast(IPAddress address)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
return address.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => bytes[0] == 10
|
||||
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||
|| (bytes[0] == 192 && bytes[1] == 168),
|
||||
AddressFamily.InterNetworkV6 => (bytes[0] & 0xfe) == 0xfc,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsGlobalIpv6(IPAddress address) =>
|
||||
!address.Equals(IPAddress.IPv6Loopback)
|
||||
&& !address.Equals(IPAddress.IPv6Any)
|
||||
&& !address.IsIPv6LinkLocal
|
||||
&& !address.IsIPv6Multicast
|
||||
&& !address.IsIPv6SiteLocal
|
||||
&& !IsPrivateUnicast(address)
|
||||
&& !IsDocumentationIpv6(address);
|
||||
|
||||
private static bool IsIpv4MulticastOrBroadcast(IPAddress address)
|
||||
{
|
||||
if (address.AddressFamily != AddressFamily.InterNetwork)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
return bytes[0] >= 224 || bytes.All(static value => value == byte.MaxValue);
|
||||
}
|
||||
|
||||
private static bool IsDocumentationIpv6(IPAddress address)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
return bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8;
|
||||
}
|
||||
|
||||
private static IPEndPoint ToIpEndpoint(ObservedEndpoint endpoint) =>
|
||||
new(IPAddress.Parse(endpoint.Address), endpoint.Port);
|
||||
}
|
||||
@@ -18,9 +18,17 @@ public sealed class UdpMediatorOptions
|
||||
[Required]
|
||||
public string ListenAddress { get; set; } = "0.0.0.0";
|
||||
|
||||
public string? Ipv6ListenAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
|
||||
/// </summary>
|
||||
[Range(0, 65_535)]
|
||||
public int Port { get; set; } = 9050;
|
||||
|
||||
[Range(1, 4_096)]
|
||||
public int MaxDatagramsPerPoll { get; set; } = 256;
|
||||
|
||||
[Range(1, 100)]
|
||||
public int PollIntervalMilliseconds { get; set; } = 2;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"Rendezvous": {
|
||||
"Udp": {
|
||||
"ListenAddress": "0.0.0.0",
|
||||
"Port": 9050
|
||||
"Port": 9050,
|
||||
"MaxDatagramsPerPoll": 256,
|
||||
"PollIntervalMilliseconds": 2
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
|
||||
Reference in New Issue
Block a user