using System.Collections.Concurrent; using System.Net; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.Transport; using FinalFactory.Rendezvous.Tests.JoinAttempts; using Microsoft.Extensions.Options; namespace FinalFactory.Rendezvous.Tests.Server; public sealed class NatMediationProcessorTests { [Fact] public void LimitedAuthenticatedUdpTrafficIsSilentlyDroppedWithoutAnIntroduction() { using JoinAttemptFixture fixture = new(); (RegisterSessionResponse registration, _) = fixture.CreateHost(bindPresence: false); AbuseProtectionOptions options = new() { UdpCapabilityDatagramsPerWindow = 1, UdpResourceDatagramsPerWindow = 10, }; AbuseProtectionService protection = new(Options.Create(options)); NatMediationProcessor processor = new( fixture.Sessions.Store, fixture.Sessions.Capabilities, fixture.Service, protection); 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( NatMediationResult.Dropped, processor.ProcessRequest( Endpoint("192.168.1.50", 40_000), Endpoint("203.0.113.77", 51_234), token, sink)); Assert.Empty(sink.Plans); } [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.True(NatIntroductionTokenCodec.TryDecode( plan.IntroductionToken, out NatIntroductionToken? introduction)); Assert.NotNull(introduction); Assert.Equal(attempt.AttemptId, introduction.AttemptId); Assert.Equal(43, introduction.ConnectionTicket.Length); Assert.DoesNotContain(introduction.ConnectionTicket, plan.ToString(), StringComparison.Ordinal); Assert.True(fixture.Sessions.Capabilities.TryFingerprint( introduction.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[] 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 CancellationAfterIntroductionCreatesAHostRevocationTombstone() { 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 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 cancelled = fixture.Service.Cancel( attempt.AttemptId, attempt.ClientCapability); Assert.True(cancelled.Succeeded); sink.Release(); Assert.Equal(NatMediationResult.Introduced, await completion); HostJoinAttempt cancelledAttempt = Assert.Single(fixture.Service.BrowseForHost( registration.ListingId, ContractLimits.ContractVersion, registration.LeaseToken, ContractLimits.BrowserPageMaxItems, null).Value!.Items); Assert.True(cancelledAttempt.IsCancelled); } [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 Plans { get; } = []; public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan); } private sealed class ConcurrentIntroductionSink : INatIntroductionSink { public ConcurrentBag 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(); } } }