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:
@@ -0,0 +1,61 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||
|
||||
public sealed class NatPunchRequestTokenCodecTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(NatPunchPeerRole.HostPresence)]
|
||||
[InlineData(NatPunchPeerRole.Host)]
|
||||
[InlineData(NatPunchPeerRole.Client)]
|
||||
public void FixedSizeTokensRoundTripBelowLiteNetLibLimit(NatPunchPeerRole role)
|
||||
{
|
||||
MediationHandle handle = new(Guid.NewGuid());
|
||||
const string capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
string encoded = NatPunchRequestTokenCodec.Encode(role, handle, capability);
|
||||
|
||||
Assert.Equal(NatPunchRequestTokenCodec.EncodedLength, encoded.Length);
|
||||
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||
Assert.True(NatPunchRequestTokenCodec.TryDecode(encoded, out NatPunchRequestToken? decoded));
|
||||
Assert.NotNull(decoded);
|
||||
Assert.Equal(role, decoded.Role);
|
||||
Assert.Equal(handle, decoded.MediationHandle);
|
||||
Assert.Equal(capability, decoded.Capability);
|
||||
Assert.DoesNotContain(capability, decoded.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedAndNonCanonicalTokensAreRejected()
|
||||
{
|
||||
string valid = NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Client,
|
||||
new MediationHandle(Guid.Parse("00112233-4455-6677-8899-aabbccddeeff")),
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
string uppercaseHandle = valid[..6]
|
||||
+ valid.Substring(6, 32).ToUpperInvariant()
|
||||
+ valid[38..];
|
||||
|
||||
Assert.False(NatPunchRequestTokenCodec.TryDecode(null, out _));
|
||||
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1], out _));
|
||||
Assert.False(NatPunchRequestTokenCodec.TryDecode("x" + valid[1..], out _));
|
||||
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1] + "x", out _));
|
||||
Assert.False(NatPunchRequestTokenCodec.TryDecode(uppercaseHandle, out _));
|
||||
Assert.Throws<ArgumentException>(() => NatPunchRequestTokenCodec.Encode(
|
||||
(NatPunchPeerRole)99,
|
||||
new MediationHandle(Guid.NewGuid()),
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedWireLengthHasADedicatedLiteNetSafeContractLimit()
|
||||
{
|
||||
Assert.Equal(192, ContractLimits.NatPunchRequestTokenCharacters);
|
||||
Assert.Equal(
|
||||
ContractLimits.NatPunchRequestTokenCharacters,
|
||||
NatPunchRequestTokenCodec.EncodedLength);
|
||||
Assert.True(
|
||||
ContractLimits.NatPunchRequestTokenCharacters
|
||||
<= ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Server;
|
||||
|
||||
public sealed class NatMediationProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuthenticatedHostPresenceUsesTheObservedGameplaySocket()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost(bindPresence: false);
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
CaptureIntroductionSink sink = new();
|
||||
string token = NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.HostPresence,
|
||||
registration.HostPresenceHandle,
|
||||
registration.HostPresenceCapability);
|
||||
|
||||
Assert.Equal(
|
||||
NatMediationResult.HostPresenceAccepted,
|
||||
processor.ProcessRequest(
|
||||
Endpoint("192.168.1.50", 40_000),
|
||||
Endpoint("203.0.113.77", 51_234),
|
||||
token,
|
||||
sink));
|
||||
Assert.Equal(registration.ListingId, Assert.Single(fixture.Sessions.Browse()).Definition.ListingId);
|
||||
Assert.Empty(sink.Plans);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
AttemptCredentials attempt = CreateAttempt(fixture, registration, "same-nat");
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
CaptureIntroductionSink sink = new();
|
||||
|
||||
Assert.Equal(
|
||||
NatMediationResult.WaitingForPeer,
|
||||
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||
Assert.Equal(
|
||||
NatMediationResult.Introduced,
|
||||
Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
|
||||
|
||||
NatIntroductionPlan plan = Assert.Single(sink.Plans);
|
||||
Assert.Equal(Endpoint("192.168.1.10", 41_000), plan.HostLocal);
|
||||
Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal);
|
||||
Assert.Equal(Endpoint("203.0.113.20", 51_000), plan.HostPublic);
|
||||
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
|
||||
Assert.Equal(43, plan.ConnectionTicket.Length);
|
||||
Assert.DoesNotContain(plan.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
|
||||
|
||||
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||
plan.ConnectionTicket,
|
||||
out SecretFingerprint ticketFingerprint));
|
||||
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||
attempt.AttemptId,
|
||||
ticketFingerprint)).Succeeded);
|
||||
|
||||
Assert.Equal(
|
||||
NatMediationResult.Duplicate,
|
||||
Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
|
||||
Assert.Single(sink.Plans);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
AttemptCredentials attempt = CreateAttempt(fixture, registration, "different-nats");
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
CaptureIntroductionSink sink = new();
|
||||
|
||||
_ = Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||
Endpoint("8.8.8.8", 42_000), Endpoint("198.51.100.40", 52_000));
|
||||
Assert.Equal(
|
||||
NatMediationResult.Introduced,
|
||||
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||
|
||||
NatIntroductionPlan plan = Assert.Single(sink.Plans);
|
||||
Assert.Equal(plan.HostPublic, plan.HostLocal);
|
||||
Assert.Equal(plan.ClientPublic, plan.ClientLocal);
|
||||
Assert.NotEqual(IPAddress.Parse("8.8.8.8"), plan.ClientLocal.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoleAndEndpointSubstitutionAreRejectedWithoutChangingTheFirstBinding()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
AttemptCredentials attempt = CreateAttempt(fixture, registration, "substitution");
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
CaptureIntroductionSink sink = new();
|
||||
|
||||
string crossRole = NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Host,
|
||||
attempt.Handle,
|
||||
attempt.ClientCapability);
|
||||
Assert.Equal(
|
||||
NatMediationResult.Dropped,
|
||||
processor.ProcessRequest(
|
||||
Endpoint("192.168.1.10", 41_000),
|
||||
Endpoint("203.0.113.20", 51_000),
|
||||
crossRole,
|
||||
sink));
|
||||
|
||||
Assert.Equal(
|
||||
NatMediationResult.WaitingForPeer,
|
||||
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||
Assert.Equal(
|
||||
NatMediationResult.Rejected,
|
||||
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||
Endpoint("192.168.1.99", 41_999), Endpoint("203.0.113.99", 51_999)));
|
||||
Assert.Equal(
|
||||
NatMediationResult.Introduced,
|
||||
Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||
Endpoint("192.168.2.10", 42_000), Endpoint("198.51.100.40", 52_000)));
|
||||
|
||||
Assert.Equal(Endpoint("203.0.113.20", 51_000), Assert.Single(sink.Plans).HostPublic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentAttemptsForOneSessionNeverCrossWire()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
AttemptCredentials first = CreateAttempt(fixture, registration, "parallel-1");
|
||||
AttemptCredentials second = CreateAttempt(fixture, registration, "parallel-2");
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
CaptureIntroductionSink sink = new();
|
||||
|
||||
_ = Process(processor, sink, first, NatPunchPeerRole.Host,
|
||||
Endpoint("10.0.0.10", 41_001), Endpoint("203.0.113.10", 51_001));
|
||||
_ = Process(processor, sink, second, NatPunchPeerRole.Host,
|
||||
Endpoint("10.0.0.20", 41_002), Endpoint("203.0.113.20", 51_002));
|
||||
_ = Process(processor, sink, second, NatPunchPeerRole.Client,
|
||||
Endpoint("10.0.0.21", 42_002), Endpoint("198.51.100.20", 52_002));
|
||||
_ = Process(processor, sink, first, NatPunchPeerRole.Client,
|
||||
Endpoint("10.0.0.11", 42_001), Endpoint("198.51.100.10", 52_001));
|
||||
|
||||
Assert.Equal(2, sink.Plans.Count);
|
||||
Assert.Contains(sink.Plans, plan =>
|
||||
plan.HostPublic.Equals(Endpoint("203.0.113.10", 51_001))
|
||||
&& plan.ClientPublic.Equals(Endpoint("198.51.100.10", 52_001)));
|
||||
Assert.Contains(sink.Plans, plan =>
|
||||
plan.HostPublic.Equals(Endpoint("203.0.113.20", 51_002))
|
||||
&& plan.ClientPublic.Equals(Endpoint("198.51.100.20", 52_002)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentDuplicateCompletionEmitsExactlyOneIntroduction()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
AttemptCredentials attempt = CreateAttempt(fixture, registration, "completion-race");
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
ConcurrentIntroductionSink sink = new();
|
||||
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
|
||||
using Barrier barrier = new(2);
|
||||
|
||||
Task<NatMediationResult>[] completions = Enumerable.Range(0, 2)
|
||||
.Select(_ => Task.Run(() =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
return Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000));
|
||||
}))
|
||||
.ToArray();
|
||||
NatMediationResult[] results = await Task.WhenAll(completions);
|
||||
|
||||
Assert.Single(results, result => result == NatMediationResult.Introduced);
|
||||
Assert.Single(sink.Plans);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationCannotReportSuccessAfterIntroductionIsConsumed()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
AttemptCredentials attempt = CreateAttempt(fixture, registration, "cancel-race");
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
using BlockingIntroductionSink sink = new();
|
||||
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
|
||||
Task<NatMediationResult> completion = Task.Run(() => Process(
|
||||
processor,
|
||||
sink,
|
||||
attempt,
|
||||
NatPunchPeerRole.Client,
|
||||
Endpoint("192.168.1.11", 42_000),
|
||||
Endpoint("198.51.100.40", 52_000)));
|
||||
Assert.True(sink.WaitUntilEntered(TimeSpan.FromSeconds(2)));
|
||||
|
||||
JoinAttemptServiceResult<bool> cancelled = fixture.Service.Cancel(
|
||||
attempt.AttemptId,
|
||||
attempt.ClientCapability);
|
||||
|
||||
Assert.Equal(RendezvousErrorCode.Conflict, cancelled.Error);
|
||||
sink.Release();
|
||||
Assert.Equal(NatMediationResult.Introduced, await completion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateFloodAmortizesGlobalExpiryMaintenance()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
AttemptCredentials attempt = CreateAttempt(fixture, registration, "maintenance-budget");
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
CaptureIntroductionSink sink = new();
|
||||
long before = fixture.Sessions.Store.MaintenanceSweepCount;
|
||||
|
||||
for (int index = 0; index < 256; index++)
|
||||
{
|
||||
Assert.Equal(
|
||||
NatMediationResult.WaitingForPeer,
|
||||
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||
}
|
||||
|
||||
Assert.InRange(fixture.Sessions.Store.MaintenanceSweepCount - before, 0, 1);
|
||||
Assert.Empty(sink.Plans);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingStaleCancelledAndMalformedRequestsNeverIntroduce()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
AttemptCredentials stale = CreateAttempt(fixture, registration, "stale");
|
||||
AttemptCredentials cancelled = CreateAttempt(fixture, registration, "cancelled");
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
CaptureIntroductionSink sink = new();
|
||||
|
||||
Assert.Equal(
|
||||
NatMediationResult.WaitingForPeer,
|
||||
Process(processor, sink, stale, NatPunchPeerRole.Client,
|
||||
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000)));
|
||||
Assert.True(fixture.Service.Cancel(cancelled.AttemptId, cancelled.ClientCapability).Succeeded);
|
||||
Assert.Equal(
|
||||
NatMediationResult.Dropped,
|
||||
Process(processor, sink, cancelled, NatPunchPeerRole.Client,
|
||||
Endpoint("192.168.1.12", 42_001), Endpoint("198.51.100.41", 52_001)));
|
||||
|
||||
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(21));
|
||||
Assert.Equal(
|
||||
NatMediationResult.Dropped,
|
||||
Process(processor, sink, stale, NatPunchPeerRole.Host,
|
||||
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||
Assert.Equal(
|
||||
NatMediationResult.Dropped,
|
||||
processor.ProcessRequest(
|
||||
Endpoint("192.168.1.10", 41_000),
|
||||
Endpoint("203.0.113.20", 51_000),
|
||||
"malformed",
|
||||
sink));
|
||||
Assert.Empty(sink.Plans);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressFamiliesMustMatchAndOnlyGlobalIpv6SourcesAreAccepted()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
AttemptCredentials mismatch = CreateAttempt(fixture, registration, "family-mismatch");
|
||||
AttemptCredentials ipv6 = CreateAttempt(fixture, registration, "ipv6");
|
||||
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||
CaptureIntroductionSink sink = new();
|
||||
|
||||
byte[] shortFrozenIpv6 = RendezvousUdpCodec.Encode(new PresenceDatagram
|
||||
{
|
||||
MessageType = UdpPresenceMessageType.ClientPresence,
|
||||
MediationHandle = ipv6.Handle,
|
||||
AddressFamily = AddressFamilyKind.Ipv6,
|
||||
LocalAddress = "fd00::11",
|
||||
LocalPort = 42_000,
|
||||
Capability = ipv6.ClientCapability,
|
||||
});
|
||||
Assert.Equal(
|
||||
NatMediationResult.Dropped,
|
||||
processor.ProcessDatagram(
|
||||
shortFrozenIpv6,
|
||||
Endpoint("2606:4700:4700::1001", 52_000),
|
||||
sink));
|
||||
|
||||
_ = Process(processor, sink, mismatch, NatPunchPeerRole.Host,
|
||||
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
|
||||
Assert.Equal(
|
||||
NatMediationResult.Rejected,
|
||||
Process(processor, sink, mismatch, NatPunchPeerRole.Client,
|
||||
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1111", 52_000)));
|
||||
|
||||
Assert.Equal(
|
||||
NatMediationResult.Dropped,
|
||||
Process(processor, sink, ipv6, NatPunchPeerRole.Host,
|
||||
Endpoint("fd00::10", 41_000), Endpoint("2001:db8::10", 51_000)));
|
||||
_ = Process(processor, sink, ipv6, NatPunchPeerRole.Host,
|
||||
Endpoint("fd00::10", 41_000), Endpoint("2606:4700:4700::1000", 51_000));
|
||||
Assert.Equal(
|
||||
NatMediationResult.Introduced,
|
||||
Process(processor, sink, ipv6, NatPunchPeerRole.Client,
|
||||
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1001", 52_000)));
|
||||
Assert.Single(sink.Plans);
|
||||
}
|
||||
|
||||
private static NatMediationProcessor CreateProcessor(JoinAttemptFixture fixture) => new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
|
||||
private static AttemptCredentials CreateAttempt(
|
||||
JoinAttemptFixture fixture,
|
||||
RegisterSessionResponse registration,
|
||||
string idempotencyKey)
|
||||
{
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, idempotencyKey);
|
||||
HostJoinAttempt host = fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||
return new(
|
||||
created.AttemptId,
|
||||
created.MediationHandle,
|
||||
host.HostPunchCapability,
|
||||
created.ClientPunchCapability);
|
||||
}
|
||||
|
||||
private static NatMediationResult Process(
|
||||
NatMediationProcessor processor,
|
||||
INatIntroductionSink sink,
|
||||
AttemptCredentials attempt,
|
||||
NatPunchPeerRole role,
|
||||
IPEndPoint local,
|
||||
IPEndPoint observed) => processor.ProcessRequest(
|
||||
local,
|
||||
observed,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
role,
|
||||
attempt.Handle,
|
||||
role == NatPunchPeerRole.Client
|
||||
? attempt.ClientCapability
|
||||
: attempt.HostCapability),
|
||||
sink);
|
||||
|
||||
private static IPEndPoint Endpoint(string address, int port) =>
|
||||
new(IPAddress.Parse(address), port);
|
||||
|
||||
private sealed record AttemptCredentials(
|
||||
JoinAttemptId AttemptId,
|
||||
MediationHandle Handle,
|
||||
string HostCapability,
|
||||
string ClientCapability);
|
||||
|
||||
private sealed class CaptureIntroductionSink : INatIntroductionSink
|
||||
{
|
||||
public List<NatIntroductionPlan> Plans { get; } = [];
|
||||
|
||||
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
|
||||
}
|
||||
|
||||
private sealed class ConcurrentIntroductionSink : INatIntroductionSink
|
||||
{
|
||||
public ConcurrentBag<NatIntroductionPlan> Plans { get; } = [];
|
||||
|
||||
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
|
||||
}
|
||||
|
||||
private sealed class BlockingIntroductionSink : INatIntroductionSink, IDisposable
|
||||
{
|
||||
private readonly ManualResetEventSlim _entered = new();
|
||||
private readonly ManualResetEventSlim _release = new();
|
||||
|
||||
public void Introduce(NatIntroductionPlan plan)
|
||||
{
|
||||
_entered.Set();
|
||||
_release.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
public bool WaitUntilEntered(TimeSpan timeout) => _entered.Wait(timeout);
|
||||
public void Release() => _release.Set();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_entered.Dispose();
|
||||
_release.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using FinalFactory.Rendezvous.Tests.Sessions;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
using LiteNetLib;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -13,55 +12,24 @@ namespace FinalFactory.Rendezvous.Tests.Server;
|
||||
public sealed class UdpMediatorServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuthenticatedHostDatagramGatesVisibilityUsingObservedGameplaySocket()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions { ListenAddress = "127.0.0.1", Port = 0 }),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
fixture.Store,
|
||||
fixture.Capabilities);
|
||||
PresenceDatagram presence = new()
|
||||
{
|
||||
MessageType = UdpPresenceMessageType.HostPresence,
|
||||
MediationHandle = registration.HostPresenceHandle,
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
LocalAddress = "192.168.1.50",
|
||||
LocalPort = 40_000,
|
||||
Capability = registration.HostPresenceCapability,
|
||||
};
|
||||
IPEndPoint observedGameplaySocket = new(IPAddress.Parse("203.0.113.77"), 51_234);
|
||||
presence.Capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
Assert.Equal(
|
||||
UdpPresenceProcessingResult.HostPresenceRejected,
|
||||
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
||||
Assert.Empty(fixture.Browse());
|
||||
|
||||
presence.Capability = registration.HostPresenceCapability;
|
||||
Assert.Equal(
|
||||
UdpPresenceProcessingResult.HostPresenceAccepted,
|
||||
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
||||
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
|
||||
public async Task ServiceBindsAnEphemeralLiteNetLibPortAndStopsCleanly()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
UdpMediatorOptions options = new()
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
};
|
||||
ManualRendezvousClock clock = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock);
|
||||
using EphemeralCapabilityIssuer capabilities = new();
|
||||
using JoinAttemptFixture fixture = new();
|
||||
NatMediationProcessor processor = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(options),
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
MaxDatagramsPerPoll = 8,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
store,
|
||||
capabilities);
|
||||
processor);
|
||||
|
||||
await service.StartAsync(timeout.Token);
|
||||
|
||||
@@ -69,9 +37,300 @@ public sealed class UdpMediatorServiceTests
|
||||
Assert.NotNull(boundEndpoint);
|
||||
Assert.Equal(IPAddress.Loopback, boundEndpoint.Address);
|
||||
Assert.InRange(boundEndpoint.Port, 1, 65_535);
|
||||
Assert.Null(service.LocalIpv6Endpoint);
|
||||
|
||||
await service.StopAsync(timeout.Token);
|
||||
|
||||
Assert.Null(service.LocalEndpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OptionalIpv6BindingNeverWidensTheRequiredIpv4Binding()
|
||||
{
|
||||
if (!Socket.OSSupportsIPv6)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using JoinAttemptFixture fixture = new();
|
||||
NatMediationProcessor processor = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Ipv6ListenAddress = IPAddress.IPv6Loopback.ToString(),
|
||||
Port = 0,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
|
||||
await service.StartAsync(timeout.Token);
|
||||
|
||||
Assert.Equal(IPAddress.Loopback, service.LocalEndpoint!.Address);
|
||||
Assert.Equal(IPAddress.IPv6Loopback, service.LocalIpv6Endpoint!.Address);
|
||||
Assert.Equal(service.LocalEndpoint.Port, service.LocalIpv6Endpoint.Port);
|
||||
IPAddress? otherIpv4 = Dns.GetHostAddresses(Dns.GetHostName())
|
||||
.FirstOrDefault(address =>
|
||||
address.AddressFamily == AddressFamily.InterNetwork
|
||||
&& !IPAddress.IsLoopback(address));
|
||||
if (otherIpv4 is not null)
|
||||
{
|
||||
using UdpClient scopeProbe = new(new IPEndPoint(otherIpv4, service.LocalEndpoint.Port));
|
||||
Assert.Equal(otherIpv4, ((IPEndPoint)scopeProbe.Client.LocalEndPoint!).Address);
|
||||
}
|
||||
|
||||
await service.StopAsync(timeout.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeLiteNetLibRequestsIntroduceTheAuthorizedPair()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "native-litenet");
|
||||
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||
NatMediationProcessor processor = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
MaxDatagramsPerPoll = 8,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
await service.StartAsync(timeout.Token);
|
||||
|
||||
EventBasedNetListener hostListener = new();
|
||||
EventBasedNetListener clientListener = new();
|
||||
NetManager host = new(hostListener) { NatPunchEnabled = true };
|
||||
NetManager client = new(clientListener) { NatPunchEnabled = true };
|
||||
EventBasedNatPunchListener hostPunch = new();
|
||||
EventBasedNatPunchListener clientPunch = new();
|
||||
List<string> hostTickets = [];
|
||||
List<string> clientTickets = [];
|
||||
hostPunch.NatIntroductionSuccess += (_, _, ticket) => hostTickets.Add(ticket);
|
||||
clientPunch.NatIntroductionSuccess += (_, _, ticket) => clientTickets.Add(ticket);
|
||||
host.NatPunchModule.Init(hostPunch);
|
||||
client.NatPunchModule.Init(clientPunch);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(host.Start(0));
|
||||
Assert.True(client.Start(0));
|
||||
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||
host.NatPunchModule.SendNatIntroduceRequest(
|
||||
mediator,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Host,
|
||||
created.MediationHandle,
|
||||
hostAttempt.HostPunchCapability));
|
||||
client.NatPunchModule.SendNatIntroduceRequest(
|
||||
mediator,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Client,
|
||||
created.MediationHandle,
|
||||
created.ClientPunchCapability));
|
||||
|
||||
while ((hostTickets.Count == 0 || clientTickets.Count == 0)
|
||||
&& !timeout.IsCancellationRequested)
|
||||
{
|
||||
host.PollEvents();
|
||||
host.NatPunchModule.PollEvents();
|
||||
client.PollEvents();
|
||||
client.NatPunchModule.PollEvents();
|
||||
await Task.Delay(5, timeout.Token);
|
||||
}
|
||||
|
||||
string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal));
|
||||
string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal));
|
||||
Assert.Equal(hostTicket, clientTicket);
|
||||
Assert.Equal(43, hostTicket.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
host.Stop();
|
||||
client.Stop();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FrozenV1EnvelopeIsConsumedOnTheLiteNetSocketWithinAmplificationBudget()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "v1-envelope");
|
||||
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||
NatMediationProcessor processor = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
MaxDatagramsPerPoll = 8,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
await service.StartAsync(timeout.Token);
|
||||
using UdpClient host = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
using UdpClient client = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||
byte[] hostDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
|
||||
{
|
||||
MessageType = UdpPresenceMessageType.HostPresence,
|
||||
MediationHandle = created.MediationHandle,
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
LocalAddress = "192.168.1.10",
|
||||
LocalPort = 41_000,
|
||||
Capability = hostAttempt.HostPunchCapability,
|
||||
});
|
||||
byte[] clientDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
|
||||
{
|
||||
MessageType = UdpPresenceMessageType.ClientPresence,
|
||||
MediationHandle = created.MediationHandle,
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
LocalAddress = "192.168.1.11",
|
||||
LocalPort = 42_000,
|
||||
Capability = created.ClientPunchCapability,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await host.SendAsync(hostDatagram, mediator, timeout.Token);
|
||||
await client.SendAsync(clientDatagram, mediator, timeout.Token);
|
||||
UdpReceiveResult hostIntroduction = await host.ReceiveAsync(timeout.Token);
|
||||
UdpReceiveResult clientIntroduction = await client.ReceiveAsync(timeout.Token);
|
||||
|
||||
Assert.True(
|
||||
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
|
||||
<= clientDatagram.Length * 2,
|
||||
"The completing authenticated contribution exceeded the 2.0 response-byte budget.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedMalformedAndGameplayDatagramsReceiveNoResponse()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using JoinAttemptFixture fixture = new();
|
||||
NatMediationProcessor processor = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
MaxDatagramsPerPoll = 8,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
await service.StartAsync(timeout.Token);
|
||||
using UdpClient sender = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||
byte[] oversized = new byte[ContractLimits.UdpDatagramMaxBytes + 1];
|
||||
oversized[0] = RendezvousUdpCodec.MagicFirst;
|
||||
oversized[1] = RendezvousUdpCodec.MagicSecond;
|
||||
byte[] gameplayPayload = [0x01, 0x02, 0x03, 0x04];
|
||||
byte[] malformedNative = [17, 0];
|
||||
|
||||
try
|
||||
{
|
||||
await sender.SendAsync(oversized, mediator, timeout.Token);
|
||||
await sender.SendAsync(gameplayPayload, mediator, timeout.Token);
|
||||
await sender.SendAsync(malformedNative, mediator, timeout.Token);
|
||||
using CancellationTokenSource noResponse = new(TimeSpan.FromMilliseconds(150));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
await sender.ReceiveAsync(noResponse.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgedNativeIntroductionResponseCannotReflectToPayloadEndpoint()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using JoinAttemptFixture fixture = new();
|
||||
NatMediationProcessor processor = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
MaxDatagramsPerPoll = 8,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
await service.StartAsync(timeout.Token);
|
||||
using UdpClient reflectedTarget = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
using UdpClient responseCapture = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
using UdpClient attacker = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
LiteNetManager generator = new(new EventBasedLiteNetListener()) { NatPunchEnabled = true };
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(generator.Start(0));
|
||||
IPEndPoint target = (IPEndPoint)reflectedTarget.Client.LocalEndPoint!;
|
||||
IPEndPoint capture = (IPEndPoint)responseCapture.Client.LocalEndPoint!;
|
||||
generator.NatPunchModule.NatIntroduce(
|
||||
target,
|
||||
new IPEndPoint(IPAddress.Loopback, 9),
|
||||
capture,
|
||||
capture,
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
byte[] forgedResponse = (await responseCapture.ReceiveAsync(timeout.Token)).Buffer;
|
||||
|
||||
await attacker.SendAsync(
|
||||
forgedResponse,
|
||||
Assert.IsType<IPEndPoint>(service.LocalEndpoint),
|
||||
timeout.Token);
|
||||
|
||||
using CancellationTokenSource noReflection = new(TimeSpan.FromMilliseconds(150));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
await reflectedTarget.ReceiveAsync(noReflection.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
generator.Stop();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
|
||||
FIELD System.Int32 ConnectionTicketMaxCharacters=192
|
||||
FIELD System.Int32 ContractVersion=1
|
||||
FIELD System.Int32 CursorMaxCharacters=512
|
||||
FIELD System.Int32 DerivedCredentialCharacters=43
|
||||
FIELD System.Int32 DiagnosticCodeMaxCharacters=64
|
||||
FIELD System.Int32 DisplayNameMaxBytes=128
|
||||
FIELD System.Int32 EnvironmentIdMaxCharacters=32
|
||||
@@ -61,6 +62,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
|
||||
FIELD System.Int32 MetadataMaxBytes=4096
|
||||
FIELD System.Int32 MetadataMaxKeys=32
|
||||
FIELD System.Int32 MetadataValueMaxBytes=256
|
||||
FIELD System.Int32 NatPunchRequestTokenCharacters=192
|
||||
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
|
||||
FIELD System.Int32 RegionIdMaxCharacters=32
|
||||
FIELD System.Int32 SessionCapacityMaxPlayers=10000
|
||||
@@ -171,6 +173,20 @@ TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
|
||||
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
|
||||
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
||||
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
||||
TYPE FinalFactory.Rendezvous.Contracts.NatPunchPeerRole
|
||||
ENUM HostPresence=1
|
||||
ENUM Host=2
|
||||
ENUM Client=3
|
||||
TYPE FinalFactory.Rendezvous.Contracts.NatPunchRequestToken
|
||||
CTOR ()
|
||||
PROP System.String Capability {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.NatPunchPeerRole Role {get;set;}
|
||||
METHOD System.String ToString()
|
||||
TYPE FinalFactory.Rendezvous.Contracts.NatPunchRequestTokenCodec
|
||||
FIELD System.Int32 EncodedLength=192
|
||||
METHOD System.String Encode(FinalFactory.Rendezvous.Contracts.NatPunchPeerRole role, FinalFactory.Rendezvous.Contracts.MediationHandle mediationHandle, System.String capability)
|
||||
METHOD System.Boolean TryDecode(System.String encoded, FinalFactory.Rendezvous.Contracts.NatPunchRequestToken& token)
|
||||
TYPE FinalFactory.Rendezvous.Contracts.NetworkEndpoint
|
||||
CTOR ()
|
||||
PROP System.String Address {get;set;}
|
||||
|
||||
Reference in New Issue
Block a user