From 7e3be2cad1d1c829df878b10fd4953bb7178a54b Mon Sep 17 00:00:00 2001 From: KyuubiYoru Date: Thu, 16 Jul 2026 11:05:56 +0200 Subject: [PATCH] feat(tooling): add standalone rendezvous test client (#25) --- README.md | 7 +- docs/integration/test-client.md | 94 +++ .../JoinAttempts/RendezvousJoinClient.cs | 3 +- .../DirectEchoProtocol.cs | 130 +++ .../HostServiceFailureBudget.cs | 36 + .../Program.cs | 31 +- .../Properties/AssemblyInfo.cs | 3 + .../README.md | 56 ++ .../RendezvousCommandRunner.cs | 774 ++++++++++++++++++ .../TestClientApplication.cs | 95 +++ .../TestClientOptions.cs | 377 +++++++++ .../TestClientOutput.cs | 181 ++++ .../Client/RendezvousJoinClientTests.cs | 7 + .../TestClient/DirectEchoProtocolTests.cs | 135 +++ .../TestClient/TestClientCommandTests.cs | 217 +++++ .../TestClientProcessIntegrationTests.cs | 587 +++++++++++++ 16 files changed, 2721 insertions(+), 12 deletions(-) create mode 100644 docs/integration/test-client.md create mode 100644 src/FinalFactory.Rendezvous.TestClient/DirectEchoProtocol.cs create mode 100644 src/FinalFactory.Rendezvous.TestClient/HostServiceFailureBudget.cs create mode 100644 src/FinalFactory.Rendezvous.TestClient/Properties/AssemblyInfo.cs create mode 100644 src/FinalFactory.Rendezvous.TestClient/README.md create mode 100644 src/FinalFactory.Rendezvous.TestClient/RendezvousCommandRunner.cs create mode 100644 src/FinalFactory.Rendezvous.TestClient/TestClientApplication.cs create mode 100644 src/FinalFactory.Rendezvous.TestClient/TestClientOptions.cs create mode 100644 src/FinalFactory.Rendezvous.TestClient/TestClientOutput.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestClient/DirectEchoProtocolTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestClient/TestClientCommandTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestClient/TestClientProcessIntegrationTests.cs diff --git a/README.md b/README.md index 41a6f34..12ea614 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,9 @@ The initial service does not provide: Rendezvous is under active roadmap development. The versioned contracts, directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned -SDK coordination, and typed connection outcomes are implemented. The thin test -client, deployment hardening, and production-readiness roadmap remain in progress; +SDK coordination, typed connection outcomes, and thin public-SDK diagnostic client +are implemented. Deployment hardening, the broader NAT-topology harness, and +the production-readiness roadmap remain in progress; participating games must not treat the current repository as a finished production service until those gates land. @@ -88,6 +89,8 @@ The frozen v1 wire surface is documented in the [HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md). Tenant policy, publisher/operator principals, and production key custody are defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md). +The scriptable host/browser/join diagnostic and its stable automation contract are +documented in the [TestClient integration guide](docs/integration/test-client.md). ## Development diff --git a/docs/integration/test-client.md b/docs/integration/test-client.md new file mode 100644 index 0000000..a60ed04 --- /dev/null +++ b/docs/integration/test-client.md @@ -0,0 +1,94 @@ +# Diagnostic TestClient integration guide + +Tracking: #25 + +`FinalFactory.Rendezvous.TestClient` is the smallest supported public-SDK consumer. +It exists for integration development, CI smoke checks, deployment verification, +and operator diagnosis. It is intentionally not a production game client, game +server, matchmaking UI, or relay. + +## Prerequisites + +Start a configured Rendezvous service and note both its HTTP base URL and UDP +mediator endpoint. The host needs a tenant-scoped publisher credential from the +deployment secret boundary. Put it in an environment variable and pass only that +variable's name when the default is unsuitable: + +```bash +export RENDEZVOUS_PUBLISHER_CREDENTIAL='' +``` + +Never put the credential in a command argument, URL, checked-in configuration, +shell trace, or captured test fixture. The development server's signing material +is process-ephemeral; credentials from a prior development process are invalid. + +## Manual three-terminal flow + +Start the host: + +```bash +dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \ + host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \ + --game space-game --environment development --region local --protocol 1 +``` + +Browse from another terminal: + +```bash +dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \ + browse --service http://127.0.0.1:5000/ \ + --game space-game --environment development --region local --protocol 1 +``` + +Join from a third terminal. Omit `--listing` for an interactive choice: + +```bash +dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \ + join --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \ + --game space-game --environment development --region local --protocol 1 \ + --listing 00000000-0000-0000-0000-000000000000 +``` + +Replace the sample UUID with the public listing ID printed by host or browse. +Host and join each create one caller-owned LiteNetLib manager. That same socket +sends presence/punch traffic, establishes the authenticated direct connection, +and carries the ping/echo/ack/completion payload. The final completion confirms +that the host received the reliable acknowledgement; none of this traffic passes through the HTTP +service or UDP mediator. + +## CI and deployment smoke flow + +Use `--script --json`, set `--listing` when deterministic selection matters, and +check the documented process exit code. `--timeout-seconds` bounds each startup, +traversal, or direct-traffic stage; a script host also uses it as its total runtime +unless `--run-seconds` is explicit. A host can add `--exit-after-echo` so it +terminates after the joining peer acknowledges direct traffic and receives the +host's completion confirmation. Every wait is +bounded by coordinator state and `--timeout-seconds`; no orchestration should use +an unbounded sleep. + +The normal test suite contains a real process gate that starts the built Server, +host TestClient, and join TestClient, waits for readiness and versioned events, +and verifies direct traffic, cleanup, JSON shape, and secret canaries. Process +trees are force-terminated in the test cleanup path if normal shutdown fails. + +Useful success events are: + +- `host.registered`, `host.ready`, `host.direct-traffic`, and `host.deregistered`; +- `browse.completed` and `browse.session`; and +- `join.connected`, `join.direct-traffic`, and `join.outcome-report`. + +Failure events preserve stable typed phases and outcomes. When a terminal outcome +contains a configured dedicated endpoint, `join.fallback` reports `available` +with endpoint type `dedicated`; no raw address is printed and no fallback is +started implicitly. + +## What the proof does and does not establish + +The deterministic loopback test proves the complete service/host/client protocol, +ticket admission, and peer-to-peer payload path. Loopback is not evidence that all +consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths, +or platform policies permit hole punching. Same-LAN, separated observed endpoints, +network namespaces/containers, mediator restart, and adverse topology coverage +belong to the topology harness tracked by #14. Production rollout still requires +tests from representative networks and a game-owned fallback policy. diff --git a/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs b/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs index 992ec51..6569347 100644 --- a/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs +++ b/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs @@ -75,7 +75,8 @@ public sealed class RendezvousJoinClient : IRendezvousJoinClient RendezvousConnectionOutcomeSource.Caller, RendezvousConnectionFailureCategory.Lifecycle, RendezvousConnectionPhase.Authorization, - elapsed.Elapsed)); + elapsed.Elapsed, + dedicatedFallback)); } } diff --git a/src/FinalFactory.Rendezvous.TestClient/DirectEchoProtocol.cs b/src/FinalFactory.Rendezvous.TestClient/DirectEchoProtocol.cs new file mode 100644 index 0000000..310334e --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/DirectEchoProtocol.cs @@ -0,0 +1,130 @@ +using System.Security.Cryptography; +using System.Text; +using LiteNetLib; +using LiteNetLib.Utils; + +namespace FinalFactory.Rendezvous.TestClient; + +internal sealed class DirectEchoProtocol : IDisposable +{ + private const string PingPrefix = "rv1-ping:"; + private const string EchoPrefix = "rv1-echo:"; + private const string AckPrefix = "rv1-ack:"; + private const string DonePrefix = "rv1-done:"; + private readonly EventBasedNetListener _events; + private readonly bool _host; + private readonly Dictionary _hostNonces = []; + private readonly TaskCompletionSource _completed = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private string? _nonce; + private bool _disposed; + + internal DirectEchoProtocol(EventBasedNetListener events, bool host) + { + _events = events ?? throw new ArgumentNullException(nameof(events)); + _host = host; + _events.NetworkReceiveEvent += OnReceive; + _events.PeerDisconnectedEvent += OnPeerDisconnected; + } + + internal Task Completion => _completed.Task; + internal int PendingHostExchangeCount => _hostNonces.Count; + internal event Action? ExchangeCompleted; + + internal void BeginJoin(NetPeer peer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_host || _nonce is not null) + { + throw new InvalidOperationException("The direct echo exchange is already active."); + } + + _nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + Send(peer, PingPrefix + _nonce); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _events.NetworkReceiveEvent -= OnReceive; + _events.PeerDisconnectedEvent -= OnPeerDisconnected; + _hostNonces.Clear(); + _disposed = true; + } + + private void OnReceive( + NetPeer peer, + NetPacketReader reader, + byte channel, + DeliveryMethod deliveryMethod) + { + try + { + ReadOnlySpan payload = reader.GetRemainingBytesSpan(); + if (payload.Length is < 9 or > 64) + { + return; + } + string message = Encoding.ASCII.GetString(payload); + if (_host && TryNonce(message, PingPrefix, out string? pingNonce)) + { + _hostNonces[peer] = pingNonce!; + Send(peer, EchoPrefix + pingNonce); + } + else if (_host + && _hostNonces.TryGetValue(peer, out string? hostNonce) + && string.Equals(message, AckPrefix + hostNonce, StringComparison.Ordinal)) + { + _hostNonces.Remove(peer); + Send(peer, DonePrefix + hostNonce); + ExchangeCompleted?.Invoke(peer); + _completed.TrySetResult(true); + } + else if (!_host + && _nonce is not null + && string.Equals(message, EchoPrefix + _nonce, StringComparison.Ordinal)) + { + Send(peer, AckPrefix + _nonce); + } + else if (!_host + && _nonce is not null + && string.Equals(message, DonePrefix + _nonce, StringComparison.Ordinal)) + { + ExchangeCompleted?.Invoke(peer); + _completed.TrySetResult(true); + } + } + finally + { + reader.Recycle(); + } + } + + private static bool TryNonce(string message, string prefix, out string? nonce) + { + nonce = null; + if (!message.StartsWith(prefix, StringComparison.Ordinal) + || message.Length != prefix.Length + 32) + { + return false; + } + string candidate = message[prefix.Length..]; + if (!candidate.All(static character => character is >= '0' and <= '9' + or >= 'a' and <= 'f')) + { + return false; + } + nonce = candidate; + return true; + } + + private static void Send(NetPeer peer, string message) => peer.Send( + Encoding.ASCII.GetBytes(message), + DeliveryMethod.ReliableOrdered); + + private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) => + _hostNonces.Remove(peer); +} diff --git a/src/FinalFactory.Rendezvous.TestClient/HostServiceFailureBudget.cs b/src/FinalFactory.Rendezvous.TestClient/HostServiceFailureBudget.cs new file mode 100644 index 0000000..2fc55e8 --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/HostServiceFailureBudget.cs @@ -0,0 +1,36 @@ +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.TestClient; + +internal sealed class HostServiceFailureBudget +{ + private const int MaximumConsecutiveTransientFailures = 3; + private int _consecutiveTransientFailures; + + internal bool ShouldStop( + RendezvousErrorCode error, + DateTimeOffset leaseExpiresAt, + DateTimeOffset now) + { + if (error == RendezvousErrorCode.None) + { + Reset(); + return false; + } + if (!IsTransient(error)) + { + return true; + } + + _consecutiveTransientFailures++; + return _consecutiveTransientFailures >= MaximumConsecutiveTransientFailures + || now >= leaseExpiresAt; + } + + internal void Reset() => _consecutiveTransientFailures = 0; + + private static bool IsTransient(RendezvousErrorCode error) => error is + RendezvousErrorCode.RateLimited + or RendezvousErrorCode.ServiceUnavailable + or RendezvousErrorCode.InternalError; +} diff --git a/src/FinalFactory.Rendezvous.TestClient/Program.cs b/src/FinalFactory.Rendezvous.TestClient/Program.cs index a0a5ba0..2f1302e 100644 --- a/src/FinalFactory.Rendezvous.TestClient/Program.cs +++ b/src/FinalFactory.Rendezvous.TestClient/Program.cs @@ -1,16 +1,29 @@ namespace FinalFactory.Rendezvous.TestClient; -/// -/// Bootstrap entry point for the public-SDK-only diagnostic client. -/// public static class Program { - /// - /// Runs the bootstrap diagnostic. - /// - public static int Main() + public static async Task Main(string[] args) { - Console.WriteLine("Rendezvous TestClient bootstrap is ready."); - return 0; + using CancellationTokenSource shutdown = new(); + ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => + { + eventArgs.Cancel = true; + shutdown.Cancel(); + }; + Console.CancelKeyPress += cancelHandler; + try + { + TestClientApplication application = new(new RendezvousCommandRunner()); + return await application.RunAsync( + args, + Console.In, + Console.Out, + Console.Error, + shutdown.Token).ConfigureAwait(false); + } + finally + { + Console.CancelKeyPress -= cancelHandler; + } } } diff --git a/src/FinalFactory.Rendezvous.TestClient/Properties/AssemblyInfo.cs b/src/FinalFactory.Rendezvous.TestClient/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..024def4 --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")] diff --git a/src/FinalFactory.Rendezvous.TestClient/README.md b/src/FinalFactory.Rendezvous.TestClient/README.md new file mode 100644 index 0000000..3826b64 --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/README.md @@ -0,0 +1,56 @@ +# FinalFactory.Rendezvous.TestClient + +This is a diagnostic executable for exercising Rendezvous through the same public +Client and Contracts API available to a game. It is not a production game client, +server browser, dedicated server, relay, account system, or gameplay host. + +The executable has three explicit modes: + +- `host` publishes a session, maintains presence and its lease, accepts an +authenticated direct peer, and answers a bounded ping/echo/ack/completion exchange; +- `browse` prints compatible public listings; and +- `join` selects or accepts a listing, drives traversal on its caller-owned + LiteNetLib socket, proves direct traffic, reports the typed outcome, and exits. + +Run `dotnet run --project src/FinalFactory.Rendezvous.TestClient -- --help` for +the complete option reference. A typical script-mode invocation is: + +```bash +export RENDEZVOUS_PUBLISHER_CREDENTIAL='' +dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \ + host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \ + --game space-game --environment development --region local --protocol 1 \ + --script --json --exit-after-echo +``` + +Publisher credentials are accepted only through a named environment variable. +There is deliberately no command-line credential option because process command +lines are routinely exposed to other local tools and diagnostics. Output uses an +allowlisted event model and never includes lease tokens, punch capabilities, +connection tickets, raw metadata, signing material, or reusable credentials. + +Script mode never prompts. Join mode selects the first compatible listing unless +`--listing UUID` fixes the choice. `--json` emits one JSON object per line with +`version: 1`; event names and the process exit codes below are stable automation +contracts. A script-mode host without `--run-seconds` uses `--timeout-seconds` as +its total runtime bound. New optional event properties may be added without changing +the version. JSON help and usage failures are versioned events as well; informational +events use stdout and failures use stderr. + +| Exit | Meaning | +|---:|---| +| `0` | Requested diagnostic flow completed successfully | +| `2` | Invalid command or options | +| `3` | Missing or invalid local configuration | +| `10` | HTTP, registration, browser, lease, or socket failure | +| `11` | No compatible session was available or selected | +| `12` | Authorization or traversal reached a typed terminal failure | +| `13` | A requested direct ping/echo proof did not complete | +| `130` | Caller cancellation or Ctrl+C | + +The client prints the selected direct endpoint category (`loopback`, `private`, or +`public`) but never the raw endpoint. A traversal failure reports whether an +authoritative dedicated fallback is available; the diagnostic does not connect to +that fallback automatically. A host may publish a policy-authorized endpoint with +`--fallback IP:PORT`. See the repository integration guide for process +orchestration and topology limitations. diff --git a/src/FinalFactory.Rendezvous.TestClient/RendezvousCommandRunner.cs b/src/FinalFactory.Rendezvous.TestClient/RendezvousCommandRunner.cs new file mode 100644 index 0000000..ca88aae --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/RendezvousCommandRunner.cs @@ -0,0 +1,774 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using FinalFactory.Rendezvous.Client; +using FinalFactory.Rendezvous.Contracts; +using LiteNetLib; + +namespace FinalFactory.Rendezvous.TestClient; + +internal sealed class RendezvousCommandRunner : ITestClientCommandRunner +{ + private static readonly TimeSpan PollDelay = TimeSpan.FromMilliseconds(5); + private static readonly TimeSpan HostRefreshInterval = TimeSpan.FromMilliseconds(250); + private static readonly TimeSpan DirectTrafficFlushGrace = TimeSpan.FromMilliseconds(500); + + public Task RunAsync( + TestClientOptions options, + TestClientOutput output, + TextReader input, + CancellationToken cancellationToken) => options.Mode switch + { + TestClientMode.Host => RunHostAsync(options, output, cancellationToken), + TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken), + TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken), + _ => Task.FromResult(TestClientExitCode.Usage), + }; + + private static async Task RunHostAsync( + TestClientOptions options, + TestClientOutput output, + CancellationToken cancellationToken) + { + string? publisherCredential = Environment.GetEnvironmentVariable( + options.PublisherCredentialEnvironmentVariable); + if (!ContractValidation.IsOpaqueHttpCredentialValid(publisherCredential)) + { + output.WriteError( + "host.configuration", + "failed", + "The publisher credential environment variable is missing or invalid.", + phase: "configuration"); + return TestClientExitCode.Configuration; + } + string credential = publisherCredential!; + + using HttpClient http = CreateHttpClient(options); + RendezvousPublisherClient publisher = new(http, ClientOptions(options)); + RendezvousSessionBrowserClient browser = new(http, ClientOptions(options)); + RendezvousJoinClient joins = new(http, ClientOptions(options)); + RendezvousNetListener events = new(); + NetManager manager = events.CreateManager(); + if (!manager.Start(options.LocalPort)) + { + output.WriteError("host.socket", "failed", "The gameplay UDP socket could not start.", phase: "presence"); + return TestClientExitCode.ServiceFailure; + } + + PublishedSession? session = null; + using CancellationTokenSource hostOperations = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task>? refresh = null; + Task>? renewal = null; + Task>? readiness = null; + DirectEchoProtocol? echo = null; + RendezvousHostCoordinator? coordinator = null; + TestClientExitCode hostResult = TestClientExitCode.ServiceFailure; + bool cleanupFailed = false; + try + { + output.Write("host.registration", "started", phase: "registration"); + RendezvousClientResult registration; + using (CancellationTokenSource registrationTimeout = CreateOperationTimeout(options, cancellationToken)) + { + try + { + registration = await publisher.RegisterAsync( + new RegisterSessionRequest + { + IdempotencyKey = Guid.NewGuid().ToString("N"), + GameId = options.GameId, + EnvironmentId = options.EnvironmentId, + RegionId = options.RegionId, + ProtocolVersion = options.ProtocolVersion, + BuildVersion = options.BuildVersion, + DisplayName = options.DisplayName, + Visibility = ListingVisibility.Public, + Capacity = new SessionCapacity { CurrentPlayers = 1, MaximumPlayers = 8 }, + Metadata = new Dictionary(options.Metadata, StringComparer.Ordinal), + DedicatedFallback = options.DedicatedFallback, + }, + credential, + registrationTimeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + output.WriteError( + "host.registration", + "timed-out", + "Host registration exceeded the bounded startup stage.", + phase: "registration"); + return TestClientExitCode.ServiceFailure; + } + } + if (!registration.IsSuccess || registration.Value is null) + { + WriteServiceFailure(output, "host.registration", "registration", registration); + return TestClientExitCode.ServiceFailure; + } + + session = registration.Value; + output.Write( + "host.registered", + "registered", + phase: "registration", + listingId: session.ListingId.ToString(), + displayName: options.DisplayName); + echo = new DirectEchoProtocol(events.GameplayEvents, host: true); + echo.ExchangeCompleted += _ => output.Write( + "host.direct-traffic", + "verified", + phase: "direct-traffic", + endpointType: "peer-to-peer"); + coordinator = new RendezvousHostCoordinator( + manager, + events, + options.Mediator, + session, + joins, + CoordinatorOptions(options)); + coordinator.AttemptCompleted += (_, completion) => + { + output.Write( + "host.attempt.completed", + completion.Outcome.IsSuccess ? "connected" : "failed", + phase: completion.Outcome.Phase.ToString(), + outcome: completion.Outcome.Kind.ToString(), + elapsedMilliseconds: ToMilliseconds(completion.Outcome.Elapsed)); + if (completion.Outcome.IsSuccess) + { + output.Write( + "host.direct-connect", + "connected", + phase: "direct-connection", + endpointType: "peer-to-peer"); + } + }; + + Stopwatch running = Stopwatch.StartNew(); + TimeSpan nextRefresh = TimeSpan.Zero; + TimeSpan nextRenewal = TimeSpan.FromSeconds(session.LeaseRenewAfterSeconds); + TimeSpan nextReadinessProbe = TimeSpan.Zero; + bool directTrafficReported = false; + TimeSpan? directTrafficCompletedAt = null; + bool ready = false; + bool terminalFailure = false; + int previousPendingAttempts = 0; + HostServiceFailureBudget refreshFailures = new(); + HostServiceFailureBudget renewalFailures = new(); + using PeriodicTimer pollTimer = new(PollDelay); + while (!cancellationToken.IsCancellationRequested) + { + coordinator.Poll(); + if (coordinator.State != RendezvousHostState.Active) + { + output.WriteError( + "host.lifecycle", + "failed", + "The host coordinator stopped before shutdown was requested.", + phase: "lifecycle", + outcome: coordinator.State.ToString()); + terminalFailure = true; + break; + } + if (coordinator.PendingAttemptCount > previousPendingAttempts) + { + output.Write( + "host.punch", + "started", + phase: "nat-traversal", + count: coordinator.PendingAttemptCount); + } + previousPendingAttempts = coordinator.PendingAttemptCount; + if (readiness is { IsCompleted: true }) + { + RendezvousClientResult result = await readiness.ConfigureAwait(false); + readiness = null; + if (result.IsSuccess) + { + ready = true; + output.Write( + "host.ready", + "ready", + phase: "presence", + listingId: session.ListingId.ToString()); + } + else + { + nextReadinessProbe = running.Elapsed + TimeSpan.FromMilliseconds(50); + } + } + if (!ready && readiness is null && running.Elapsed >= nextReadinessProbe) + { + readiness = browser.GetAsync( + session.ListingId, + options.GameId, + options.EnvironmentId, + options.ProtocolVersion, + hostOperations.Token); + } + if (refresh is { IsCompleted: true }) + { + RendezvousClientResult result = await refresh.ConfigureAwait(false); + refresh = null; + nextRefresh = running.Elapsed + (result.IsSuccess + ? HostRefreshInterval + : TimeSpan.FromSeconds(1)); + if (!result.IsSuccess) + { + WriteServiceFailure(output, "host.authorization", "authorization", result); + if (refreshFailures.ShouldStop(result.Error, session.ExpiresAt, DateTimeOffset.UtcNow)) + { + terminalFailure = true; + break; + } + } + else + { + refreshFailures.Reset(); + } + } + if (refresh is null && running.Elapsed >= nextRefresh) + { + refresh = coordinator.RefreshJoinAttemptsAsync(hostOperations.Token); + } + + if (renewal is { IsCompleted: true }) + { + RendezvousClientResult result = await renewal.ConfigureAwait(false); + renewal = null; + nextRenewal = running.Elapsed + (result.IsSuccess && result.Value is not null + ? TimeSpan.FromSeconds(result.Value.RenewAfterSeconds) + : TimeSpan.FromSeconds(1)); + output.Write( + "host.lease", + result.IsSuccess ? "renewed" : "failed", + phase: "lease", + message: result.IsSuccess ? null : SafeServiceMessage(result)); + if (!result.IsSuccess + && renewalFailures.ShouldStop(result.Error, session.ExpiresAt, DateTimeOffset.UtcNow)) + { + terminalFailure = true; + break; + } + if (result.IsSuccess) + { + renewalFailures.Reset(); + } + } + if (renewal is null && running.Elapsed >= nextRenewal) + { + renewal = publisher.RenewAsync(session, credential, hostOperations.Token); + } + + if (echo.Completion.IsCompleted && !directTrafficReported) + { + directTrafficReported = true; + directTrafficCompletedAt = running.Elapsed; + } + if (options.ExitAfterEcho + && directTrafficCompletedAt.HasValue + && running.Elapsed - directTrafficCompletedAt.Value >= DirectTrafficFlushGrace) + { + break; + } + if (options.RunDuration.HasValue && running.Elapsed >= options.RunDuration.Value) + { + break; + } + if (!await pollTimer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + break; + } + } + + if (cancellationToken.IsCancellationRequested) + { + hostResult = TestClientExitCode.Cancelled; + } + else if (terminalFailure) + { + hostResult = TestClientExitCode.ServiceFailure; + } + else if (options.ExitAfterEcho && !directTrafficReported) + { + output.WriteError( + "host.direct-traffic", + "timed-out", + "No authenticated ping/echo/ack exchange completed within the host runtime.", + phase: "direct-traffic"); + hostResult = TestClientExitCode.DirectTrafficFailed; + } + else + { + hostResult = TestClientExitCode.Success; + } + } + finally + { + hostOperations.Cancel(); + await ObserveCancellationAsync(refresh).ConfigureAwait(false); + await ObserveCancellationAsync(renewal).ConfigureAwait(false); + await ObserveCancellationAsync(readiness).ConfigureAwait(false); + coordinator?.Dispose(); + echo?.Dispose(); + if (session is not null) + { + using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5)); + try + { + RendezvousClientResult deregistered = await publisher.DeregisterAsync( + session, + credential, + cleanup.Token).ConfigureAwait(false); + output.Write( + "host.deregistered", + deregistered.IsSuccess ? "complete" : "failed", + phase: "lifecycle", + listingId: session.ListingId.ToString()); + if (!deregistered.IsSuccess) + { + cleanupFailed = true; + } + } + catch (OperationCanceledException) + { + output.WriteError( + "host.deregistered", + "timed-out", + "Deregistration did not complete within the cleanup budget.", + phase: "lifecycle"); + cleanupFailed = true; + } + } + manager.Stop(); + } + return cleanupFailed && !cancellationToken.IsCancellationRequested + ? TestClientExitCode.ServiceFailure + : hostResult; + } + + private static async Task RunBrowseAsync( + TestClientOptions options, + TestClientOutput output, + CancellationToken cancellationToken) + { + using CancellationTokenSource operation = CreateOperationTimeout(options, cancellationToken); + using HttpClient http = CreateHttpClient(options); + RendezvousSessionBrowserClient browser = new(http, ClientOptions(options)); + output.Write("browse.sessions", "started", phase: "directory"); + RendezvousClientResult> result = await browser.BrowseAllAsync( + BrowseRequest(options), + maximumPages: 10, + cancellationToken: operation.Token).ConfigureAwait(false); + if (!result.IsSuccess || result.Value is null) + { + WriteServiceFailure(output, "browse.sessions", "directory", result); + return TestClientExitCode.ServiceFailure; + } + + WriteListings(output, result.Value); + return result.Value.Count == 0 + ? TestClientExitCode.NoCompatibleSession + : TestClientExitCode.Success; + } + + private static async Task RunJoinAsync( + TestClientOptions options, + TestClientOutput output, + TextReader input, + CancellationToken cancellationToken) + { + using CancellationTokenSource operation = CreateOperationTimeout(options, cancellationToken); + using HttpClient http = CreateHttpClient(options); + RendezvousSessionBrowserClient browser = new(http, ClientOptions(options)); + RendezvousJoinClient joins = new(http, ClientOptions(options)); + SessionSelection selection = await SelectListingAsync( + options, + output, + input, + browser, + operation.Token).ConfigureAwait(false); + if (selection.Listing is null) + { + return selection.ExitCode; + } + SessionListing listing = selection.Listing; + + RendezvousNetListener events = new(); + NetManager manager = events.CreateManager(); + if (!manager.Start(options.LocalPort)) + { + output.WriteError("join.socket", "failed", "The gameplay UDP socket could not start.", phase: "mediation"); + return TestClientExitCode.ServiceFailure; + } + + RendezvousClientCoordinator? coordinator = null; + bool directConnected = false; + try + { + output.Write( + "join.authorization", + "started", + phase: "authorization", + listingId: listing.ListingId.ToString()); + RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync( + new CreateJoinAttemptRequest + { + IdempotencyKey = Guid.NewGuid().ToString("N"), + GameId = options.GameId, + EnvironmentId = options.EnvironmentId, + ListingId = listing.ListingId, + ProtocolVersion = options.ProtocolVersion, + }, + listing.DedicatedFallback, + operation.Token).ConfigureAwait(false); + if (start.Outcome is { } serviceOutcome) + { + cancellationToken.ThrowIfCancellationRequested(); + WriteOutcome(output, "join.authorization", serviceOutcome); + WriteFallback(output, serviceOutcome); + return TestClientExitCode.TraversalFailed; + } + + CreateJoinAttemptResponse attempt = start.Attempt + ?? throw new InvalidOperationException("The typed start result had no attempt or outcome."); + using DirectEchoProtocol echo = new(events.GameplayEvents, host: false); + coordinator = new RendezvousClientCoordinator( + manager, + events, + options.Mediator, + attempt, + CoordinatorOptions(options)); + output.Write("join.punch", "started", phase: "nat-traversal"); + using (CancellationTokenSource traversal = CreateOperationTimeout(options, cancellationToken)) + using (PeriodicTimer traversalPoll = new(PollDelay)) + { + RendezvousConnectionState previousState = coordinator.State; + while (!coordinator.IsCompleted) + { + traversal.Token.ThrowIfCancellationRequested(); + coordinator.Poll(); + if (coordinator.State != previousState) + { + previousState = coordinator.State; + if (previousState == RendezvousConnectionState.Connecting) + { + output.Write( + "join.direct-connect", + "started", + phase: "direct-connection"); + } + } + if (!coordinator.IsCompleted + && !await traversalPoll.WaitForNextTickAsync(traversal.Token).ConfigureAwait(false)) + { + break; + } + } + } + + RendezvousConnectionOutcome outcome = coordinator.Outcome + ?? throw new InvalidOperationException("The completed coordinator had no typed outcome."); + WriteOutcome(output, "join.traversal", outcome); + if (!outcome.IsSuccess || coordinator.ConnectedPeer is null) + { + WriteFallback(output, outcome); + await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false); + return TestClientExitCode.TraversalFailed; + } + + NetPeer peer = coordinator.ConnectedPeer; + directConnected = true; + string endpointType = EndpointType(peer.Address); + output.Write( + "join.connected", + "connected", + phase: "direct-connection", + endpointType: endpointType, + elapsedMilliseconds: ToMilliseconds(outcome.Elapsed)); + await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false); + echo.BeginJoin(peer); + using (CancellationTokenSource traffic = CreateOperationTimeout(options, cancellationToken)) + using (PeriodicTimer trafficPoll = new(PollDelay)) + { + while (!echo.Completion.IsCompleted) + { + traffic.Token.ThrowIfCancellationRequested(); + manager.PollEvents(); + if (!echo.Completion.IsCompleted + && !await trafficPoll.WaitForNextTickAsync(traffic.Token).ConfigureAwait(false)) + { + break; + } + } + } + await echo.Completion.ConfigureAwait(false); + output.Write( + "join.direct-traffic", + "verified", + phase: "direct-traffic", + endpointType: endpointType); + peer.Disconnect(); + manager.PollEvents(); + return TestClientExitCode.Success; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + output.WriteError( + "join.timeout", + "timed-out", + "The bounded join operation timed out.", + phase: "lifecycle", + outcome: ConnectionOutcomeKind.TimedOut.ToString()); + if (coordinator is not null && !coordinator.IsCompleted) + { + coordinator.Poll(); + } + if (coordinator is not null && !coordinator.IsCompleted) + { + coordinator.Cancel(); + coordinator.Poll(); + if (coordinator.Outcome is { } timeoutOutcome) + { + WriteOutcome(output, "join.traversal", timeoutOutcome); + WriteFallback(output, timeoutOutcome, listing.DedicatedFallback); + await ReportOutcomeAsync( + coordinator, + joins, + output, + cancellationToken).ConfigureAwait(false); + } + } + return directConnected + ? TestClientExitCode.DirectTrafficFailed + : TestClientExitCode.TraversalFailed; + } + finally + { + coordinator?.Dispose(); + manager.Stop(); + } + } + + private static async Task SelectListingAsync( + TestClientOptions options, + TestClientOutput output, + TextReader input, + RendezvousSessionBrowserClient browser, + CancellationToken cancellationToken) + { + if (options.ListingId.HasValue) + { + RendezvousClientResult exact = await browser.GetAsync( + options.ListingId.Value, + options.GameId, + options.EnvironmentId, + options.ProtocolVersion, + cancellationToken).ConfigureAwait(false); + if (!exact.IsSuccess || exact.Value is null) + { + WriteServiceFailure(output, "join.selection", "directory", exact); + return new(null, TestClientExitCode.ServiceFailure); + } + return new(exact.Value.Session, TestClientExitCode.Success); + } + + RendezvousClientResult> result = await browser.BrowseAllAsync( + BrowseRequest(options), + maximumPages: 10, + cancellationToken: cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess || result.Value is null) + { + WriteServiceFailure(output, "join.selection", "directory", result); + return new(null, TestClientExitCode.ServiceFailure); + } + if (result.Value.Count == 0) + { + output.Write("join.selection", "empty", phase: "directory", count: 0); + return new(null, TestClientExitCode.NoCompatibleSession); + } + WriteListings(output, result.Value); + if (options.Script) + { + return new(result.Value[0], TestClientExitCode.Success); + } + + output.WritePrompt($"Select session [1-{result.Value.Count}]: "); + string? selection = await input.ReadLineAsync(cancellationToken).ConfigureAwait(false); + SessionListing? selected = int.TryParse(selection, out int index) + && index >= 1 + && index <= result.Value.Count + ? result.Value[index - 1] + : null; + return selected is null + ? new(null, TestClientExitCode.NoCompatibleSession) + : new(selected, TestClientExitCode.Success); + } + + private static void WriteListings(TestClientOutput output, IReadOnlyList listings) + { + output.Write("browse.completed", "complete", phase: "directory", count: listings.Count); + foreach (SessionListing listing in listings) + { + output.Write( + "browse.session", + "available", + phase: "directory", + listingId: listing.ListingId.ToString(), + displayName: listing.DisplayName); + } + } + + private static BrowseSessionsRequest BrowseRequest(TestClientOptions options) => new() + { + GameId = options.GameId, + EnvironmentId = options.EnvironmentId, + ProtocolVersion = options.ProtocolVersion, + RegionId = options.RegionId, + PageSize = options.PageSize, + ExcludeFull = true, + }; + + private static HttpClient CreateHttpClient(TestClientOptions options) => new() + { + BaseAddress = options.ServiceUri, + Timeout = Timeout.InfiniteTimeSpan, + }; + + private static RendezvousClientOptions ClientOptions(TestClientOptions options) => new() + { + RequestTimeout = TimeSpan.FromSeconds(Math.Min(30, options.OperationTimeout.TotalSeconds)), + }; + + private static RendezvousCoordinatorOptions CoordinatorOptions(TestClientOptions options) + { + TimeSpan phaseTimeout = TimeSpan.FromSeconds( + Math.Min(30, options.OperationTimeout.TotalSeconds * 0.45)); + return new RendezvousCoordinatorOptions + { + PunchTimeout = phaseTimeout, + DirectConnectTimeout = phaseTimeout, + }; + } + + private static CancellationTokenSource CreateOperationTimeout( + TestClientOptions options, + CancellationToken cancellationToken) + { + CancellationTokenSource source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + source.CancelAfter(options.OperationTimeout); + return source; + } + + private static async Task ReportOutcomeAsync( + RendezvousClientCoordinator coordinator, + RendezvousJoinClient joins, + TestClientOutput output, + CancellationToken callerCancellationToken) + { + using CancellationTokenSource telemetry = CancellationTokenSource.CreateLinkedTokenSource( + callerCancellationToken); + telemetry.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + RendezvousClientResult report = + await coordinator.ReportOutcomeAsync(joins, telemetry.Token).ConfigureAwait(false); + output.Write( + "join.outcome-report", + report.IsSuccess ? "accepted" : "failed", + phase: "telemetry", + message: report.IsSuccess ? null : SafeServiceMessage(report)); + } + catch (OperationCanceledException) when (!callerCancellationToken.IsCancellationRequested) + { + output.WriteError( + "join.outcome-report", + "cancelled", + "Outcome reporting was cancelled within the operation budget.", + phase: "telemetry"); + } + } + + private static void WriteOutcome( + TestClientOutput output, + string eventName, + RendezvousConnectionOutcome outcome) => output.Write( + eventName, + outcome.IsSuccess ? "connected" : "failed", + phase: outcome.Phase.ToString(), + outcome: outcome.Kind.ToString(), + elapsedMilliseconds: ToMilliseconds(outcome.Elapsed)); + + private static void WriteFallback( + TestClientOutput output, + RendezvousConnectionOutcome outcome, + NetworkEndpoint? authoritativeFallback = null) + { + bool hasFallback = outcome.HasDedicatedFallback || authoritativeFallback is not null; + output.Write( + "join.fallback", + hasFallback ? "available" : "unavailable", + phase: "fallback", + outcome: outcome.Kind.ToString(), + endpointType: hasFallback ? "dedicated" : "none"); + } + + private static void WriteServiceFailure( + TestClientOutput output, + string eventName, + string phase, + RendezvousClientResult result) => output.WriteError( + eventName, + "failed", + SafeServiceMessage(result), + phase, + result.Error.ToString()); + + private static string SafeServiceMessage(RendezvousClientResult result) => + $"Rendezvous returned {result.Error}."; + + private static async Task ObserveCancellationAsync(Task? task) + { + if (task is null) + { + return; + } + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + } + + private static string EndpointType(IPAddress address) + { + if (IPAddress.IsLoopback(address)) + { + return "loopback"; + } + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + byte[] ipv6 = address.GetAddressBytes(); + return address.IsIPv6LinkLocal || (ipv6[0] & 0xfe) == 0xfc + ? "private" + : "public"; + } + byte[] bytes = address.GetAddressBytes(); + bool privateAddress = bytes[0] == 10 + || bytes[0] == 172 && bytes[1] is >= 16 and <= 31 + || bytes[0] == 192 && bytes[1] == 168; + return privateAddress ? "private" : "public"; + } + + private static long ToMilliseconds(TimeSpan elapsed) => + (long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds)); + + private sealed record SessionSelection( + SessionListing? Listing, + TestClientExitCode ExitCode); +} diff --git a/src/FinalFactory.Rendezvous.TestClient/TestClientApplication.cs b/src/FinalFactory.Rendezvous.TestClient/TestClientApplication.cs new file mode 100644 index 0000000..e2c1642 --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/TestClientApplication.cs @@ -0,0 +1,95 @@ +namespace FinalFactory.Rendezvous.TestClient; + +internal enum TestClientExitCode +{ + Success = 0, + Usage = 2, + Configuration = 3, + ServiceFailure = 10, + NoCompatibleSession = 11, + TraversalFailed = 12, + DirectTrafficFailed = 13, + Cancelled = 130, +} + +internal interface ITestClientCommandRunner +{ + Task RunAsync( + TestClientOptions options, + TestClientOutput output, + TextReader input, + CancellationToken cancellationToken); +} + +internal sealed class TestClientApplication(ITestClientCommandRunner runner) +{ + private readonly ITestClientCommandRunner _runner = runner ?? throw new ArgumentNullException(nameof(runner)); + + internal async Task RunAsync( + string[] args, + TextReader input, + TextWriter standardOutput, + TextWriter standardError, + CancellationToken cancellationToken) + { + bool jsonRequested = args.Contains("--json", StringComparer.Ordinal); + TestClientParseResult parsed = TestClientOptionParser.Parse(args); + TestClientOutput output = new(standardOutput, standardError, jsonRequested); + if (parsed.ShowHelp) + { + if (jsonRequested) + { + output.Write( + "cli.help", + "complete", + phase: "configuration", + message: "Run without --json to read the full command reference."); + } + else + { + await standardOutput.WriteLineAsync(TestClientOptionParser.Usage).ConfigureAwait(false); + } + return (int)TestClientExitCode.Success; + } + if (!parsed.Succeeded || parsed.Options is null) + { + if (jsonRequested) + { + output.WriteError( + "cli.usage", + "failed", + parsed.Error ?? "Invalid command line.", + phase: "configuration"); + } + else + { + await standardError.WriteLineAsync(parsed.Error ?? "Invalid command line.").ConfigureAwait(false); + await standardError.WriteLineAsync("Use --help for documented options.").ConfigureAwait(false); + } + return (int)TestClientExitCode.Usage; + } + + output = new TestClientOutput(standardOutput, standardError, parsed.Options.Json); + try + { + return (int)await _runner.RunAsync( + parsed.Options, + output, + input, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + output.Write("lifecycle.cancelled", "cancelled", phase: "lifecycle"); + return (int)TestClientExitCode.Cancelled; + } + catch (Exception exception) + { + output.WriteError( + "lifecycle.failed", + "failed", + $"Unexpected {exception.GetType().Name}; credentials remain redacted."); + return (int)TestClientExitCode.ServiceFailure; + } + } +} diff --git a/src/FinalFactory.Rendezvous.TestClient/TestClientOptions.cs b/src/FinalFactory.Rendezvous.TestClient/TestClientOptions.cs new file mode 100644 index 0000000..76127dc --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/TestClientOptions.cs @@ -0,0 +1,377 @@ +using System.Net; +using System.Net.Sockets; +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.TestClient; + +internal enum TestClientMode +{ + Host, + Browse, + Join, +} + +internal sealed class TestClientOptions +{ + internal TestClientMode Mode { get; init; } + internal Uri ServiceUri { get; init; } = new("http://127.0.0.1:5000/"); + internal IPEndPoint Mediator { get; init; } = new(IPAddress.Loopback, 9050); + internal GameId GameId { get; init; } = new("space-game"); + internal EnvironmentId EnvironmentId { get; init; } = new("development"); + internal RegionId RegionId { get; init; } = new("local"); + internal uint ProtocolVersion { get; init; } = 1; + internal string BuildVersion { get; init; } = "test-client"; + internal string DisplayName { get; init; } = "Rendezvous diagnostic host"; + internal string PublisherCredentialEnvironmentVariable { get; init; } = + "RENDEZVOUS_PUBLISHER_CREDENTIAL"; + internal Dictionary Metadata { get; init; } = new(StringComparer.Ordinal); + internal NetworkEndpoint? DedicatedFallback { get; init; } + internal SessionListingId? ListingId { get; init; } + internal int LocalPort { get; init; } + internal int PageSize { get; init; } = 20; + internal TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(20); + internal TimeSpan? RunDuration { get; init; } + internal bool Script { get; init; } + internal bool Json { get; init; } + internal bool ExitAfterEcho { get; init; } +} + +internal sealed class TestClientParseResult +{ + private TestClientParseResult(TestClientOptions? options, string? error, bool showHelp) + { + Options = options; + Error = error; + ShowHelp = showHelp; + } + + internal TestClientOptions? Options { get; } + internal string? Error { get; } + internal bool ShowHelp { get; } + internal bool Succeeded => Options is not null; + + internal static TestClientParseResult Success(TestClientOptions options) => new(options, null, false); + internal static TestClientParseResult Failure(string error) => new(null, error, false); + internal static TestClientParseResult Help() => new(null, null, true); +} + +internal static class TestClientOptionParser +{ + internal const string Usage = """ + Rendezvous diagnostic client + + Usage: + rendezvous-test-client host [options] + rendezvous-test-client browse [options] + rendezvous-test-client join [options] + + Common options: + --service URL HTTP(S) Rendezvous base URL + --mediator IP:PORT UDP mediator endpoint + --game ID Game scope (default: space-game) + --environment ID Environment scope (default: development) + --protocol NUMBER Exact gameplay protocol (default: 1) + --region ID Region filter/publication (default: local) + --timeout-seconds NUMBER Bounded startup/traversal stage, 1-300 (default: 20) + --port NUMBER Caller-owned gameplay UDP port; 0 chooses one + --page-size NUMBER Bounded browser page size, 1-100 (default: 20) + --script Never prompt; select the first compatible listing + --json Emit one versioned JSON event per line + --help Show this help + + Host options: + --publisher-credential-env NAME Environment variable containing the credential + --display-name TEXT Public listing name + --build-version TEXT Public build version + --metadata KEY=VALUE Bounded public metadata; may be repeated + --fallback IP:PORT Optional policy-authorized dedicated fallback + --run-seconds NUMBER Stop after 1-86400 seconds + --exit-after-echo Stop after an authenticated ping/echo/ack exchange + + Join options: + --listing UUID Join an exact listing; otherwise browse/select + + Credentials are accepted only through the named environment variable. They are never + accepted on the command line and are never written to human or JSON output. + """; + + internal static TestClientParseResult Parse(string[] args) + { + if (args.Length == 0 || args.Length == 1 && IsHelp(args[0])) + { + return TestClientParseResult.Help(); + } + if (args.Length > 64) + { + return TestClientParseResult.Failure("Too many command-line arguments."); + } + if (!TryMode(args[0], out TestClientMode mode)) + { + return TestClientParseResult.Failure("The first argument must be host, browse, or join."); + } + + Uri serviceUri = new("http://127.0.0.1:5000/"); + IPEndPoint mediator = new(IPAddress.Loopback, 9050); + string game = "space-game"; + string environment = "development"; + string region = "local"; + uint protocol = 1; + string buildVersion = "test-client"; + string displayName = "Rendezvous diagnostic host"; + string credentialEnvironmentVariable = "RENDEZVOUS_PUBLISHER_CREDENTIAL"; + Dictionary metadata = new(StringComparer.Ordinal); + NetworkEndpoint? dedicatedFallback = null; + SessionListingId? listingId = null; + int localPort = 0; + int pageSize = 20; + int timeoutSeconds = 20; + int? runSeconds = null; + bool script = false; + bool json = false; + bool exitAfterEcho = false; + HashSet seen = new(StringComparer.Ordinal); + + for (int index = 1; index < args.Length; index++) + { + string option = args[index]; + if (IsHelp(option)) + { + return TestClientParseResult.Help(); + } + if (option is "--script" or "--json" or "--exit-after-echo") + { + if (!seen.Add(option)) + { + return TestClientParseResult.Failure($"Option {option} was specified more than once."); + } + script |= option == "--script"; + json |= option == "--json"; + exitAfterEcho |= option == "--exit-after-echo"; + continue; + } + if (!option.StartsWith("--", StringComparison.Ordinal) + || index + 1 >= args.Length) + { + return TestClientParseResult.Failure("Every option must use the form --name value."); + } + + string value = args[++index]; + if (value.Length is 0 or > 512) + { + return TestClientParseResult.Failure($"Option {option} has an invalid value length."); + } + if (option != "--metadata" && !seen.Add(option)) + { + return TestClientParseResult.Failure($"Option {option} was specified more than once."); + } + + switch (option) + { + case "--service": + if (!TryServiceUri(value, out serviceUri)) + { + return TestClientParseResult.Failure("The service URL must be absolute HTTP(S), credential-free, and query-free."); + } + break; + case "--mediator": + if (!IPEndPoint.TryParse(value, out IPEndPoint? parsedMediator) + || parsedMediator.Port == 0) + { + return TestClientParseResult.Failure("The mediator must be an IP endpoint with a non-zero port."); + } + mediator = parsedMediator; + break; + case "--game": + game = value; + break; + case "--environment": + environment = value; + break; + case "--region": + region = value; + break; + case "--protocol": + if (!uint.TryParse(value, out protocol) || protocol == 0) + { + return TestClientParseResult.Failure("The protocol must be a positive integer."); + } + break; + case "--build-version": + buildVersion = value; + break; + case "--display-name": + displayName = value; + break; + case "--publisher-credential-env": + if (!IsEnvironmentVariableName(value)) + { + return TestClientParseResult.Failure("The credential environment-variable name is invalid."); + } + credentialEnvironmentVariable = value; + break; + case "--metadata": + if (!TryMetadata(value, metadata)) + { + return TestClientParseResult.Failure("Metadata must be a unique KEY=VALUE pair with a non-empty key."); + } + break; + case "--fallback": + if (!IPEndPoint.TryParse(value, out IPEndPoint? fallbackEndpoint) + || fallbackEndpoint.Port == 0) + { + return TestClientParseResult.Failure("The fallback must be an IP endpoint with a non-zero port."); + } + dedicatedFallback = new NetworkEndpoint + { + AddressFamily = fallbackEndpoint.AddressFamily == AddressFamily.InterNetwork + ? AddressFamilyKind.Ipv4 + : AddressFamilyKind.Ipv6, + Address = fallbackEndpoint.Address.ToString(), + Port = fallbackEndpoint.Port, + }; + break; + case "--listing": + if (!Guid.TryParse(value, out Guid parsedListing) || parsedListing == Guid.Empty) + { + return TestClientParseResult.Failure("The listing must be a non-empty UUID."); + } + listingId = new SessionListingId(parsedListing); + break; + case "--port": + if (!int.TryParse(value, out localPort) || localPort is < 0 or > 65_535) + { + return TestClientParseResult.Failure("The local UDP port must be between 0 and 65535."); + } + break; + case "--page-size": + if (!int.TryParse(value, out pageSize) + || pageSize is < 1 or > ContractLimits.BrowserPageMaxItems) + { + return TestClientParseResult.Failure("The page size is outside the contract limit."); + } + break; + case "--timeout-seconds": + if (!int.TryParse(value, out timeoutSeconds) || timeoutSeconds is < 1 or > 300) + { + return TestClientParseResult.Failure("The timeout must be between 1 and 300 seconds."); + } + break; + case "--run-seconds": + if (!int.TryParse(value, out int parsedRunSeconds) + || parsedRunSeconds is < 1 or > 86_400) + { + return TestClientParseResult.Failure("The host run duration must be between 1 and 86400 seconds."); + } + runSeconds = parsedRunSeconds; + break; + default: + return TestClientParseResult.Failure($"Unknown option {option}."); + } + } + + if (!IsSlug(game, ContractLimits.GameIdMaxCharacters) + || !IsSlug(environment, ContractLimits.EnvironmentIdMaxCharacters) + || !IsSlug(region, ContractLimits.RegionIdMaxCharacters) + || !ContractValidation.IsBuildVersionValid(buildVersion) + || !ContractValidation.IsDisplayNameValid(displayName) + || !ContractValidation.IsMetadataValid(metadata)) + { + return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits."); + } + if (listingId.HasValue && mode != TestClientMode.Join + || runSeconds.HasValue && mode != TestClientMode.Host + || exitAfterEcho && mode != TestClientMode.Host + || metadata.Count > 0 && mode != TestClientMode.Host + || dedicatedFallback is not null && mode != TestClientMode.Host + || seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host + || seen.Contains("--display-name") && mode != TestClientMode.Host + || seen.Contains("--build-version") && mode != TestClientMode.Host) + { + return TestClientParseResult.Failure("One or more options do not apply to the selected mode."); + } + + return TestClientParseResult.Success(new TestClientOptions + { + Mode = mode, + ServiceUri = serviceUri, + Mediator = mediator, + GameId = new(game), + EnvironmentId = new(environment), + RegionId = new(region), + ProtocolVersion = protocol, + BuildVersion = buildVersion, + DisplayName = displayName, + PublisherCredentialEnvironmentVariable = credentialEnvironmentVariable, + Metadata = metadata, + DedicatedFallback = dedicatedFallback, + ListingId = listingId, + LocalPort = localPort, + PageSize = pageSize, + OperationTimeout = TimeSpan.FromSeconds(timeoutSeconds), + RunDuration = runSeconds.HasValue + ? TimeSpan.FromSeconds(runSeconds.Value) + : mode == TestClientMode.Host && script + ? TimeSpan.FromSeconds(timeoutSeconds) + : null, + Script = script, + Json = json, + ExitAfterEcho = exitAfterEcho, + }); + } + + private static bool TryMode(string value, out TestClientMode mode) => + Enum.TryParse(value, true, out mode) && Enum.IsDefined(mode); + + private static bool IsHelp(string value) => value is "--help" or "-h" or "help"; + + private static bool TryServiceUri(string value, out Uri uri) + { + uri = null!; + if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed) + || parsed.Scheme is not ("http" or "https") + || !string.IsNullOrEmpty(parsed.UserInfo) + || !string.IsNullOrEmpty(parsed.Query) + || !string.IsNullOrEmpty(parsed.Fragment)) + { + return false; + } + + UriBuilder builder = new(parsed) { Path = parsed.AbsolutePath.TrimEnd('/') + "/" }; + uri = builder.Uri; + return true; + } + + private static bool IsEnvironmentVariableName(string value) + { + if (value.Length is 0 or > 64 || !(char.IsLetter(value[0]) || value[0] == '_')) + { + return false; + } + return value.All(static character => + char.IsAsciiLetterOrDigit(character) || character == '_'); + } + + private static bool TryMetadata(string value, Dictionary metadata) + { + int separator = value.IndexOf('='); + if (separator is < 1 or > ContractLimits.MetadataKeyMaxBytes + || metadata.Count >= ContractLimits.MetadataMaxKeys) + { + return false; + } + string key = value[..separator]; + string metadataValue = value[(separator + 1)..]; + return !string.IsNullOrWhiteSpace(key) + && ContractValidation.IsUtf8LengthWithin(key, ContractLimits.MetadataKeyMaxBytes) + && ContractValidation.IsUtf8LengthWithin(metadataValue, ContractLimits.MetadataValueMaxBytes) + && metadata.TryAdd(key, metadataValue); + } + + private static bool IsSlug(string value, int maximumCharacters) => + value.Length is > 0 + && value.Length <= maximumCharacters + && value[0] is >= 'a' and <= 'z' + && value.All(static character => character is >= 'a' and <= 'z' + or >= '0' and <= '9' + or '-'); +} diff --git a/src/FinalFactory.Rendezvous.TestClient/TestClientOutput.cs b/src/FinalFactory.Rendezvous.TestClient/TestClientOutput.cs new file mode 100644 index 0000000..c4582f4 --- /dev/null +++ b/src/FinalFactory.Rendezvous.TestClient/TestClientOutput.cs @@ -0,0 +1,181 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace FinalFactory.Rendezvous.TestClient; + +internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter standardError, bool json) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = false, + }; + + private readonly object _gate = new(); + private readonly TextWriter _standardOutput = standardOutput ?? throw new ArgumentNullException(nameof(standardOutput)); + private readonly TextWriter _standardError = standardError ?? throw new ArgumentNullException(nameof(standardError)); + private readonly bool _json = json; + + internal void Write( + string eventName, + string status, + string? phase = null, + string? listingId = null, + string? displayName = null, + string? outcome = null, + string? endpointType = null, + int? count = null, + long? elapsedMilliseconds = null, + string? message = null) => WriteCore( + _standardOutput, + new TestClientEvent + { + Event = SafeToken(eventName) ?? string.Empty, + Status = SafeToken(status) ?? string.Empty, + Phase = SafeToken(phase), + ListingId = SafeToken(listingId), + DisplayName = SafeText(displayName), + Outcome = SafeToken(outcome), + EndpointType = SafeToken(endpointType), + Count = count, + ElapsedMilliseconds = elapsedMilliseconds, + Message = SafeText(message), + }); + + internal void WriteError( + string eventName, + string status, + string message, + string? phase = null, + string? outcome = null) => WriteCore( + _standardError, + new TestClientEvent + { + Event = SafeToken(eventName) ?? string.Empty, + Status = SafeToken(status) ?? string.Empty, + Phase = SafeToken(phase), + Outcome = SafeToken(outcome), + Message = SafeText(message), + }); + + internal void WritePrompt(string prompt) + { + if (_json) + { + return; + } + lock (_gate) + { + _standardOutput.Write(SafeText(prompt)); + _standardOutput.Flush(); + } + } + + private void WriteCore(TextWriter writer, TestClientEvent item) + { + string line = _json + ? JsonSerializer.Serialize(item, JsonOptions) + : HumanLine(item); + lock (_gate) + { + writer.WriteLine(line); + writer.Flush(); + } + } + + private static string HumanLine(TestClientEvent item) + { + StringBuilder line = new(); + line.Append('[').Append(item.Status).Append("] ").Append(item.Event); + Append(line, "phase", item.Phase); + Append(line, "listing", item.ListingId); + Append(line, "name", item.DisplayName, quote: true); + Append(line, "outcome", item.Outcome); + Append(line, "endpoint", item.EndpointType); + if (item.Count.HasValue) + { + Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + if (item.ElapsedMilliseconds.HasValue) + { + Append( + line, + "elapsedMs", + item.ElapsedMilliseconds.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + Append(line, "message", item.Message, quote: true); + return line.ToString(); + } + + private static void Append( + StringBuilder builder, + string name, + string? value, + bool quote = false) + { + if (!string.IsNullOrEmpty(value)) + { + builder.Append(' ').Append(name).Append('='); + if (quote) + { + builder.Append('"').Append(value.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"'); + } + else + { + builder.Append(value); + } + } + } + + private static string? SafeToken(string? value) + { + if (value is null) + { + return null; + } + return new string(value + .Take(96) + .Select(static character => char.IsAsciiLetterOrDigit(character) + || character is '.' or '-' or '_' or ':' + ? char.ToLowerInvariant(character) + : '_') + .ToArray()); + } + + private static string? SafeText(string? value) + { + if (value is null) + { + return null; + } + return new string(value + .Take(160) + .Select(static character => IsUnsafeHumanCharacter(character) ? '?' : character) + .ToArray()); + } + + private static bool IsUnsafeHumanCharacter(char character) => + char.GetUnicodeCategory(character) is + UnicodeCategory.Control + or UnicodeCategory.Format + or UnicodeCategory.LineSeparator + or UnicodeCategory.ParagraphSeparator + or UnicodeCategory.Surrogate + or UnicodeCategory.PrivateUse; + + private sealed class TestClientEvent + { + public int Version { get; init; } = 1; + public string Event { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public string? Phase { get; init; } + public string? ListingId { get; init; } + public string? DisplayName { get; init; } + public string? Outcome { get; init; } + public string? EndpointType { get; init; } + public int? Count { get; init; } + public long? ElapsedMilliseconds { get; init; } + public string? Message { get; init; } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs index 40bd4f4..065ec99 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs @@ -161,10 +161,17 @@ public sealed class RendezvousJoinClientTests RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync( CreateRequest("cancelled-before-send"), + new NetworkEndpoint + { + AddressFamily = AddressFamilyKind.Ipv4, + Address = "203.0.113.90", + Port = 7777, + }, cancellationToken: cancellation.Token); Assert.Empty(handler.Requests); Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind); + Assert.True(result.Outcome.HasDedicatedFallback); Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source); } diff --git a/tests/FinalFactory.Rendezvous.Tests/TestClient/DirectEchoProtocolTests.cs b/tests/FinalFactory.Rendezvous.Tests/TestClient/DirectEchoProtocolTests.cs new file mode 100644 index 0000000..9537a54 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/TestClient/DirectEchoProtocolTests.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Text; +using FinalFactory.Rendezvous.TestClient; +using LiteNetLib; + +namespace FinalFactory.Rendezvous.Tests.TestClient; + +public sealed class DirectEchoProtocolTests +{ + [Fact] + public async Task HostReleasesPendingNonceWhenPeerDisconnectsBeforeAcknowledgement() + { + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3)); + EventBasedNetListener hostEvents = new(); + EventBasedNetListener clientEvents = new(); + hostEvents.ConnectionRequestEvent += request => request.Accept(); + NetPeer? clientPeer = null; + clientEvents.PeerConnectedEvent += peer => clientPeer = peer; + NetManager hostManager = new(hostEvents); + NetManager clientManager = new(clientEvents); + try + { + Assert.True(hostManager.Start(0)); + Assert.True(clientManager.Start(0)); + clientManager.Connect( + new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort), + "echo-test"); + await PumpUntilAsync( + () => clientPeer is not null, + hostManager, + clientManager, + clientManager, + timeout.Token); + using DirectEchoProtocol host = new(hostEvents, host: true); + clientPeer!.Send( + Encoding.ASCII.GetBytes("rv1-ping:00112233445566778899aabbccddeeff"), + DeliveryMethod.ReliableOrdered); + await PumpUntilAsync( + () => host.PendingHostExchangeCount == 1, + hostManager, + clientManager, + clientManager, + timeout.Token); + + clientPeer.Disconnect(); + await PumpUntilAsync( + () => host.PendingHostExchangeCount == 0, + hostManager, + clientManager, + clientManager, + timeout.Token); + + Assert.Equal(0, host.PendingHostExchangeCount); + } + finally + { + clientManager.Stop(); + hostManager.Stop(); + } + } + + [Fact] + public async Task HostVerifiesOverlappingPeersAgainstTheirOwnNonces() + { + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3)); + EventBasedNetListener hostEvents = new(); + EventBasedNetListener firstEvents = new(); + EventBasedNetListener secondEvents = new(); + hostEvents.ConnectionRequestEvent += request => request.Accept(); + NetPeer? firstPeer = null; + NetPeer? secondPeer = null; + firstEvents.PeerConnectedEvent += peer => firstPeer = peer; + secondEvents.PeerConnectedEvent += peer => secondPeer = peer; + NetManager hostManager = new(hostEvents); + NetManager firstManager = new(firstEvents); + NetManager secondManager = new(secondEvents); + try + { + Assert.True(hostManager.Start(0)); + Assert.True(firstManager.Start(0)); + Assert.True(secondManager.Start(0)); + IPEndPoint hostEndpoint = new(IPAddress.Loopback, hostManager.LocalPort); + firstManager.Connect(hostEndpoint, "echo-test"); + secondManager.Connect(hostEndpoint, "echo-test"); + await PumpUntilAsync( + () => firstPeer is not null && secondPeer is not null, + hostManager, + firstManager, + secondManager, + timeout.Token); + + using DirectEchoProtocol host = new(hostEvents, host: true); + using DirectEchoProtocol first = new(firstEvents, host: false); + using DirectEchoProtocol second = new(secondEvents, host: false); + int hostCompletions = 0; + host.ExchangeCompleted += _ => hostCompletions++; + first.BeginJoin(firstPeer!); + second.BeginJoin(secondPeer!); + + await PumpUntilAsync( + () => first.Completion.IsCompleted + && second.Completion.IsCompleted + && hostCompletions == 2, + hostManager, + firstManager, + secondManager, + timeout.Token); + + Assert.Equal(2, hostCompletions); + } + finally + { + firstManager.Stop(); + secondManager.Stop(); + hostManager.Stop(); + } + } + + private static async Task PumpUntilAsync( + Func predicate, + NetManager host, + NetManager first, + NetManager second, + CancellationToken cancellationToken) + { + while (!predicate()) + { + cancellationToken.ThrowIfCancellationRequested(); + host.PollEvents(); + first.PollEvents(); + second.PollEvents(); + await Task.Delay(2, cancellationToken); + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/TestClient/TestClientCommandTests.cs b/tests/FinalFactory.Rendezvous.Tests/TestClient/TestClientCommandTests.cs new file mode 100644 index 0000000..44be390 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/TestClient/TestClientCommandTests.cs @@ -0,0 +1,217 @@ +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.TestClient; + +namespace FinalFactory.Rendezvous.Tests.TestClient; + +public sealed class TestClientCommandTests +{ + [Fact] + public void HostOptionsParseBoundedPublicConfigurationWithoutAcceptingASecretArgument() + { + TestClientParseResult parsed = TestClientOptionParser.Parse( + [ + "host", + "--service", "https://rendezvous.example/base", + "--mediator", "127.0.0.1:9050", + "--game", "space-game", + "--environment", "production", + "--region", "eu-central", + "--protocol", "7", + "--metadata", "mode=online-coop", + "--fallback", "203.0.113.50:7777", + "--publisher-credential-env", "TEST_PUBLISHER_CREDENTIAL", + "--script", + "--json", + "--exit-after-echo", + ]); + + Assert.True(parsed.Succeeded, parsed.Error); + TestClientOptions options = Assert.IsType(parsed.Options); + Assert.Equal(TestClientMode.Host, options.Mode); + Assert.Equal(new Uri("https://rendezvous.example/base/"), options.ServiceUri); + Assert.Equal(7u, options.ProtocolVersion); + Assert.Equal("online-coop", options.Metadata["mode"]); + Assert.Equal("203.0.113.50", options.DedicatedFallback?.Address); + Assert.Equal(7777, options.DedicatedFallback?.Port); + Assert.Equal("TEST_PUBLISHER_CREDENTIAL", options.PublisherCredentialEnvironmentVariable); + Assert.True(options.Script); + Assert.True(options.Json); + Assert.True(options.ExitAfterEcho); + Assert.Equal(TimeSpan.FromSeconds(20), options.RunDuration); + + TestClientParseResult secret = TestClientOptionParser.Parse( + ["host", "--publisher-credential", "secret-canary"]); + Assert.False(secret.Succeeded); + Assert.Contains("Unknown option", secret.Error, StringComparison.Ordinal); + } + + [Fact] + public void ScriptExitCodesRemainStable() + { + Assert.Equal(0, (int)TestClientExitCode.Success); + Assert.Equal(2, (int)TestClientExitCode.Usage); + Assert.Equal(3, (int)TestClientExitCode.Configuration); + Assert.Equal(10, (int)TestClientExitCode.ServiceFailure); + Assert.Equal(11, (int)TestClientExitCode.NoCompatibleSession); + Assert.Equal(12, (int)TestClientExitCode.TraversalFailed); + Assert.Equal(13, (int)TestClientExitCode.DirectTrafficFailed); + Assert.Equal(130, (int)TestClientExitCode.Cancelled); + } + + [Fact] + public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + HostServiceFailureBudget authority = new(); + Assert.True(authority.ShouldStop( + RendezvousErrorCode.NotFound, + now.AddMinutes(1), + now)); + + HostServiceFailureBudget transient = new(); + Assert.False(transient.ShouldStop( + RendezvousErrorCode.ServiceUnavailable, + now.AddMinutes(1), + now)); + Assert.False(transient.ShouldStop( + RendezvousErrorCode.RateLimited, + now.AddMinutes(1), + now)); + Assert.True(transient.ShouldStop( + RendezvousErrorCode.InternalError, + now.AddMinutes(1), + now)); + + transient.Reset(); + Assert.True(transient.ShouldStop( + RendezvousErrorCode.ServiceUnavailable, + now, + now)); + } + + [Theory] + [InlineData("https://user:password@rendezvous.example/")] + [InlineData("file:///tmp/rendezvous")] + [InlineData("https://rendezvous.example/?token=secret")] + public void ServiceUrlRejectsCredentialAndNonHttpShapes(string url) + { + TestClientParseResult parsed = TestClientOptionParser.Parse(["browse", "--service", url]); + + Assert.False(parsed.Succeeded); + Assert.Contains("service URL", parsed.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ApplicationRoutesParsedOptionsThroughTheInjectableUiFlow() + { + FakeCommandRunner runner = new(TestClientExitCode.NoCompatibleSession); + TestClientApplication application = new(runner); + StringWriter output = new(); + StringWriter error = new(); + + int exitCode = await application.RunAsync( + ["browse", "--script", "--json"], + new StringReader(string.Empty), + output, + error, + CancellationToken.None); + + Assert.Equal((int)TestClientExitCode.NoCompatibleSession, exitCode); + Assert.NotNull(runner.Options); + Assert.Equal(TestClientMode.Browse, runner.Options.Mode); + Assert.True(runner.Options.Script); + using JsonDocument item = JsonDocument.Parse(output.ToString()); + Assert.Equal(1, item.RootElement.GetProperty("version").GetInt32()); + Assert.Equal("fake.completed", item.RootElement.GetProperty("event").GetString()); + Assert.Equal(string.Empty, error.ToString()); + } + + [Fact] + public async Task InvalidArgumentsFailBeforeTheRunnerAndDoNotEchoTheValue() + { + FakeCommandRunner runner = new(TestClientExitCode.Success); + TestClientApplication application = new(runner); + StringWriter output = new(); + StringWriter error = new(); + + int exitCode = await application.RunAsync( + ["host", "--publisher-credential", "secret-canary"], + new StringReader(string.Empty), + output, + error, + CancellationToken.None); + + Assert.Equal((int)TestClientExitCode.Usage, exitCode); + Assert.Null(runner.Options); + Assert.DoesNotContain("secret-canary", error.ToString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("host", "--json", "--unknown", "value", "cli.usage", 2)] + [InlineData("host", "--json", "--help", "", "cli.help", 0)] + public async Task JsonModeKeepsHelpAndUsageFailuresMachineReadable( + string mode, + string json, + string option, + string value, + string expectedEvent, + int expectedExit) + { + FakeCommandRunner runner = new(TestClientExitCode.Success); + TestClientApplication application = new(runner); + StringWriter output = new(); + StringWriter error = new(); + string[] args = string.IsNullOrEmpty(value) + ? [mode, json, option] + : [mode, json, option, value]; + + int exitCode = await application.RunAsync( + args, + new StringReader(string.Empty), + output, + error, + CancellationToken.None); + + Assert.Equal(expectedExit, exitCode); + string jsonLine = expectedExit == 0 ? output.ToString() : error.ToString(); + using JsonDocument item = JsonDocument.Parse(jsonLine); + Assert.Equal(expectedEvent, item.RootElement.GetProperty("event").GetString()); + } + + [Fact] + public void HumanOutputNeutralizesControlCharactersFromPublicListingText() + { + StringWriter output = new(); + TestClientOutput sink = new(output, new StringWriter(), json: false); + + sink.Write( + "browse.session", + "available", + displayName: "host\nforged-line\u001b[31m outcome=connected\u2028next\u2029line\u202eright"); + + string line = output.ToString(); + Assert.Equal(1, line.Count(static character => character == '\n')); + Assert.DoesNotContain('\u001b', line); + Assert.DoesNotContain('\u2028', line); + Assert.DoesNotContain('\u2029', line); + Assert.DoesNotContain('\u202e', line); + Assert.Contains("name=\"host?forged-line?[31m outcome=connected?next?line?right\"", line, StringComparison.Ordinal); + } + + private sealed class FakeCommandRunner(TestClientExitCode exitCode) : ITestClientCommandRunner + { + internal TestClientOptions? Options { get; private set; } + + public Task RunAsync( + TestClientOptions options, + TestClientOutput output, + TextReader input, + CancellationToken cancellationToken) + { + Options = options; + output.Write("fake.completed", "complete", phase: "test"); + return Task.FromResult(exitCode); + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/TestClient/TestClientProcessIntegrationTests.cs b/tests/FinalFactory.Rendezvous.Tests/TestClient/TestClientProcessIntegrationTests.cs new file mode 100644 index 0000000..a307335 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/TestClient/TestClientProcessIntegrationTests.cs @@ -0,0 +1,587 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.TestClient; + +namespace FinalFactory.Rendezvous.Tests.TestClient; + +public sealed class TestClientProcessIntegrationTests +{ +#if DEBUG + private const string BuildConfiguration = "Debug"; +#else + private const string BuildConfiguration = "Release"; +#endif + + [Fact] + public async Task ServerHostAndJoinProcessesExchangeAuthenticatedDirectTrafficWithoutLeakingSecrets() + { + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); + string root = RepositoryRoot(); + string serverAssembly = Path.Combine( + root, + $"src/FinalFactory.Rendezvous.Server/bin/{BuildConfiguration}/net10.0/FinalFactory.Rendezvous.Server.dll"); + string clientAssembly = Path.Combine( + root, + $"src/FinalFactory.Rendezvous.TestClient/bin/{BuildConfiguration}/net8.0/FinalFactory.Rendezvous.TestClient.dll"); + Assert.True(File.Exists(serverAssembly), $"Missing server build output: {serverAssembly}"); + Assert.True(File.Exists(clientAssembly), $"Missing TestClient build output: {clientAssembly}"); + + DateTimeOffset now = DateTimeOffset.UtcNow; + byte[] signingKey = RandomNumberGenerator.GetBytes(32); + string signingKeyText = Convert.ToBase64String(signingKey); + string publisherCredential = IssuePublisherCredential(signingKey, now); + CryptographicOperations.ZeroMemory(signingKey); + Dictionary serverEnvironment = ServerEnvironment( + signingKeyText, + now); + + await using ProcessCapture server = Start( + serverAssembly, + [], + serverEnvironment); + string httpLine = await server.WaitForLineAsync("Now listening on: http://127.0.0.1:", timeout.Token); + string serviceUrl = ParseServiceUrl(httpLine); + string udpLine = await server.WaitForLineAsync("UDP mediator listening on 127.0.0.1:", timeout.Token); + int udpPort = ParseTrailingPort(udpLine); + await WaitForReadyAsync(serviceUrl, server, timeout.Token); + + string[] emptyBrowseArguments = + [ + "browse", + "--service", serviceUrl, + "--game", "space-game", + "--environment", "integration", + "--region", "local", + "--protocol", "1", + "--script", + "--json", + "--timeout-seconds", "15", + ]; + await using ProcessCapture emptyBrowse = Start( + clientAssembly, + emptyBrowseArguments, + new Dictionary(StringComparer.Ordinal)); + Assert.Equal( + (int)TestClientExitCode.NoCompatibleSession, + await emptyBrowse.WaitForExitAsync(timeout.Token)); + Assert.True(emptyBrowse.HasEvent("browse.completed", "complete"), emptyBrowse.DiagnosticText()); + Assert.Contains( + emptyBrowse.JsonEvents(), + item => item.GetProperty("event").GetString() == "browse.completed" + && item.GetProperty("count").GetInt32() == 0); + + string[] missingJoinArguments = + [ + "join", + "--service", serviceUrl, + "--mediator", $"127.0.0.1:{udpPort}", + "--game", "space-game", + "--environment", "integration", + "--region", "local", + "--protocol", "1", + "--listing", Guid.NewGuid().ToString("D"), + "--script", + "--json", + "--timeout-seconds", "15", + ]; + await using ProcessCapture missingJoin = Start( + clientAssembly, + missingJoinArguments, + new Dictionary(StringComparer.Ordinal)); + Assert.Equal((int)TestClientExitCode.ServiceFailure, await missingJoin.WaitForExitAsync(timeout.Token)); + Assert.True(missingJoin.HasEvent("join.selection", "failed"), missingJoin.DiagnosticText()); + + string[] boundedHostArguments = + [ + "host", + "--service", serviceUrl, + "--mediator", $"127.0.0.1:{udpPort}", + "--game", "space-game", + "--environment", "integration", + "--region", "local", + "--protocol", "1", + "--script", + "--json", + "--exit-after-echo", + "--run-seconds", "1", + "--timeout-seconds", "15", + ]; + await using ProcessCapture boundedHost = Start( + clientAssembly, + boundedHostArguments, + new Dictionary(StringComparer.Ordinal) + { + ["RENDEZVOUS_PUBLISHER_CREDENTIAL"] = publisherCredential, + }); + Assert.Equal((int)TestClientExitCode.DirectTrafficFailed, await boundedHost.WaitForExitAsync(timeout.Token)); + Assert.True(boundedHost.HasEvent("host.direct-traffic", "timed-out"), boundedHost.DiagnosticText()); + Assert.True(boundedHost.HasEvent("host.deregistered", "complete"), boundedHost.DiagnosticText()); + + string[] hostArguments = + [ + "host", + "--service", serviceUrl, + "--mediator", $"127.0.0.1:{udpPort}", + "--game", "space-game", + "--environment", "integration", + "--region", "local", + "--protocol", "1", + "--script", + "--json", + "--exit-after-echo", + "--timeout-seconds", "15", + ]; + await using ProcessCapture host = Start( + clientAssembly, + hostArguments, + new Dictionary(StringComparer.Ordinal) + { + ["RENDEZVOUS_PUBLISHER_CREDENTIAL"] = publisherCredential, + }); + JsonElement hostRegistered = await host.WaitForEventAsync( + "host.ready", + timeout.Token); + string listingId = Assert.IsType(hostRegistered.GetProperty("listingId").GetString()); + + string[] browseArguments = + [ + "browse", + "--service", serviceUrl, + "--game", "space-game", + "--environment", "integration", + "--region", "local", + "--protocol", "1", + "--script", + "--json", + "--timeout-seconds", "15", + ]; + await using ProcessCapture browse = Start( + clientAssembly, + browseArguments, + new Dictionary(StringComparer.Ordinal)); + Assert.Equal(0, await browse.WaitForExitAsync(timeout.Token)); + Assert.True(browse.HasEvent("browse.completed", "complete"), browse.DiagnosticText()); + Assert.True( + browse.JsonEvents().Any(item => + item.GetProperty("event").GetString() == "browse.session" + && item.GetProperty("status").GetString() == "available" + && item.GetProperty("listingId").GetString() == listingId), + browse.DiagnosticText()); + + string[] joinArguments = + [ + "join", + "--service", serviceUrl, + "--mediator", $"127.0.0.1:{udpPort}", + "--game", "space-game", + "--environment", "integration", + "--region", "local", + "--protocol", "1", + "--listing", listingId, + "--script", + "--json", + "--timeout-seconds", "15", + ]; + await using ProcessCapture join = Start( + clientAssembly, + joinArguments, + new Dictionary(StringComparer.Ordinal)); + + Assert.Equal(0, await join.WaitForExitAsync(timeout.Token)); + Assert.Equal(0, await host.WaitForExitAsync(timeout.Token)); + Assert.True(join.HasEvent("join.connected", "connected"), join.DiagnosticText()); + Assert.True(join.HasEvent("join.punch", "started"), join.DiagnosticText()); + Assert.True(join.HasEvent("join.direct-connect", "started"), join.DiagnosticText()); + Assert.True(join.HasEvent("join.direct-traffic", "verified"), join.DiagnosticText()); + Assert.True(join.HasEvent("join.outcome-report", "accepted"), join.DiagnosticText()); + Assert.True(host.HasEvent("host.direct-traffic", "verified"), host.DiagnosticText()); + Assert.True(host.HasEvent("host.punch", "started"), host.DiagnosticText()); + Assert.True(host.HasEvent("host.direct-connect", "connected"), host.DiagnosticText()); + Assert.True(host.HasEvent("host.deregistered", "complete"), host.DiagnosticText()); + Assert.Equal(host.AllLines().Count(), host.JsonEvents().Count); + Assert.Equal(emptyBrowse.AllLines().Count(), emptyBrowse.JsonEvents().Count); + Assert.Equal(missingJoin.AllLines().Count(), missingJoin.JsonEvents().Count); + Assert.Equal(browse.AllLines().Count(), browse.JsonEvents().Count); + Assert.Equal(join.AllLines().Count(), join.JsonEvents().Count); + Assert.All( + host.JsonEvents() + .Concat(emptyBrowse.JsonEvents()) + .Concat(missingJoin.JsonEvents()) + .Concat(browse.JsonEvents()) + .Concat(join.JsonEvents()), + AssertAllowlistedEventShape); + + using HttpClient service = new() { BaseAddress = new Uri(serviceUrl) }; + using HttpResponseMessage removed = await service.GetAsync( + $"v1/sessions/{listingId}?contractVersion=1&gameId=space-game&environmentId=integration&protocolVersion=1", + timeout.Token); + Assert.Equal(HttpStatusCode.NotFound, removed.StatusCode); + + string captured = string.Join( + '\n', + server.AllLines() + .Concat(boundedHost.AllLines()) + .Concat(emptyBrowse.AllLines()) + .Concat(missingJoin.AllLines()) + .Concat(host.AllLines()) + .Concat(browse.AllLines()) + .Concat(join.AllLines())); + Assert.False( + captured.Contains(publisherCredential, StringComparison.Ordinal), + "Captured process output contained the publisher credential."); + Assert.False( + captured.Contains(signingKeyText, StringComparison.Ordinal), + "Captured process output contained signing-key material."); + } + + private static string IssuePublisherCredential(byte[] signingKey, DateTimeOffset now) + { + const string secretReference = "env:RENDEZVOUS_INTEGRATION_SIGNING_KEY"; + ProvisioningOptions options = ProvisioningOptions(now, secretReference); + using DictionarySecretProvider secrets = new(new Dictionary(StringComparer.Ordinal) + { + [secretReference] = signingKey, + }); + using ProvisioningRuntime provisioning = ProvisioningRuntime.Create(options, secrets, now); + return provisioning.Credentials.Issue( + new DedicatedPublisherPrincipal( + "test-client-process-host", + now.AddMinutes(5), + new GameId("space-game"), + new EnvironmentId("integration"), + [new RegionId("local")]), + now); + } + + private static ProvisioningOptions ProvisioningOptions( + DateTimeOffset now, + string secretReference) => new() + { + Issuer = "rendezvous-process-test", + Audience = "rendezvous-process-test-client", + ClockSkewSeconds = 5, + SigningKeys = + [ + new SigningKeyOptions + { + KeyId = "process-test-key", + SecretReference = secretReference, + CredentialKinds = [PrincipalCredentialKind.DedicatedPublisher], + GameId = "space-game", + EnvironmentId = "integration", + NotBefore = now.AddMinutes(-1), + SignUntil = now.AddMinutes(10), + VerifyUntil = now.AddMinutes(20), + }, + ], + Games = + [ + new GamePolicyOptions + { + GameId = "space-game", + EnvironmentId = "integration", + Enabled = true, + ProtocolVersions = [1], + Regions = ["local"], + VisibilityModes = [ListingVisibility.Public], + PublisherTrustModes = [PublisherTrustMode.ManagedDedicated], + MetadataMaxBytes = 256, + MetadataMaxKeys = 2, + MaxListingsPerPrincipal = 4, + MaxAnonymousListingsPerAddress = 1, + MaxActiveJoinAttempts = 16, + }, + ], + }; + + private static Dictionary ServerEnvironment( + string signingKey, + DateTimeOffset now) + { + Dictionary values = new(StringComparer.Ordinal) + { + ["ASPNETCORE_ENVIRONMENT"] = "Production", + ["ASPNETCORE_URLS"] = "http://127.0.0.1:0", + ["Rendezvous__Udp__ListenAddress"] = "127.0.0.1", + ["Rendezvous__Udp__Port"] = "0", + ["Rendezvous__Udp__PollIntervalMilliseconds"] = "1", + ["Rendezvous__Provisioning__Issuer"] = "rendezvous-process-test", + ["Rendezvous__Provisioning__Audience"] = "rendezvous-process-test-client", + ["Rendezvous__Provisioning__ClockSkewSeconds"] = "5", + ["Rendezvous__Provisioning__SigningKeys__0__KeyId"] = "process-test-key", + ["Rendezvous__Provisioning__SigningKeys__0__SecretReference"] = + "env:RENDEZVOUS_INTEGRATION_SIGNING_KEY", + ["Rendezvous__Provisioning__SigningKeys__0__CredentialKinds__0"] = "DedicatedPublisher", + ["Rendezvous__Provisioning__SigningKeys__0__GameId"] = "space-game", + ["Rendezvous__Provisioning__SigningKeys__0__EnvironmentId"] = "integration", + ["Rendezvous__Provisioning__SigningKeys__0__NotBefore"] = now.AddMinutes(-1).ToString("O"), + ["Rendezvous__Provisioning__SigningKeys__0__SignUntil"] = now.AddMinutes(10).ToString("O"), + ["Rendezvous__Provisioning__SigningKeys__0__VerifyUntil"] = now.AddMinutes(20).ToString("O"), + ["Rendezvous__Provisioning__Games__0__GameId"] = "space-game", + ["Rendezvous__Provisioning__Games__0__EnvironmentId"] = "integration", + ["Rendezvous__Provisioning__Games__0__Enabled"] = "true", + ["Rendezvous__Provisioning__Games__0__ProtocolVersions__0"] = "1", + ["Rendezvous__Provisioning__Games__0__Regions__0"] = "local", + ["Rendezvous__Provisioning__Games__0__VisibilityModes__0"] = "Public", + ["Rendezvous__Provisioning__Games__0__PublisherTrustModes__0"] = "ManagedDedicated", + ["Rendezvous__Provisioning__Games__0__MetadataMaxBytes"] = "256", + ["Rendezvous__Provisioning__Games__0__MetadataMaxKeys"] = "2", + ["Rendezvous__Provisioning__Games__0__MaxListingsPerPrincipal"] = "4", + ["Rendezvous__Provisioning__Games__0__MaxAnonymousListingsPerAddress"] = "1", + ["Rendezvous__Provisioning__Games__0__MaxActiveJoinAttempts"] = "16", + ["RENDEZVOUS_INTEGRATION_SIGNING_KEY"] = signingKey, + }; + return values; + } + + private static ProcessCapture Start( + string assembly, + IReadOnlyList arguments, + IReadOnlyDictionary environment) + { + ProcessStartInfo start = new() + { + FileName = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + start.ArgumentList.Add(assembly); + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + foreach (KeyValuePair item in environment) + { + start.Environment[item.Key] = item.Value; + } + return new ProcessCapture(start); + } + + private static async Task WaitForReadyAsync( + string serviceUrl, + ProcessCapture server, + CancellationToken cancellationToken) + { + using HttpClient client = new() { BaseAddress = new Uri(serviceUrl) }; + while (!cancellationToken.IsCancellationRequested) + { + if (server.HasExited) + { + throw new Xunit.Sdk.XunitException($"Server exited before readiness. {server.DiagnosticText()}"); + } + try + { + using HttpResponseMessage response = await client.GetAsync( + "health/ready", + cancellationToken); + if (response.StatusCode == HttpStatusCode.OK) + { + return; + } + } + catch (HttpRequestException) + { + } + await Task.Delay(25, cancellationToken); + } + cancellationToken.ThrowIfCancellationRequested(); + } + + private static string ParseServiceUrl(string line) + { + const string marker = "Now listening on: "; + string value = line[(line.IndexOf(marker, StringComparison.Ordinal) + marker.Length)..].Trim(); + return new Uri(value).AbsoluteUri; + } + + private static int ParseTrailingPort(string line) + { + string value = line[(line.LastIndexOf(':') + 1)..].Trim(); + return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + } + + private static string RepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx"))) + { + return directory.FullName; + } + directory = directory.Parent; + } + throw new DirectoryNotFoundException("Could not locate the Rendezvous repository root."); + } + + private static void AssertAllowlistedEventShape(JsonElement item) + { + string[] forbidden = ["credential", "capability", "ticket", "token", "secret", "metadata"]; + foreach (JsonProperty property in item.EnumerateObject()) + { + Assert.DoesNotContain(forbidden, forbiddenName => + property.Name.Contains(forbiddenName, StringComparison.OrdinalIgnoreCase)); + } + } + + private sealed class ProcessCapture : IAsyncDisposable + { + private readonly Process _process; + private readonly ConcurrentQueue _standardOutput = new(); + private readonly ConcurrentQueue _standardError = new(); + + internal ProcessCapture(ProcessStartInfo start) + { + _process = new Process { StartInfo = start }; + _process.OutputDataReceived += (_, eventArgs) => Add(eventArgs.Data, _standardOutput); + _process.ErrorDataReceived += (_, eventArgs) => Add(eventArgs.Data, _standardError); + Assert.True(_process.Start(), $"Failed to start {start.FileName}."); + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + } + + internal bool HasExited => _process.HasExited; + + internal async Task WaitForEventAsync( + string eventName, + CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + foreach (JsonElement item in JsonEvents()) + { + if (item.TryGetProperty("event", out JsonElement eventProperty) + && eventProperty.GetString() == eventName) + { + return item; + } + } + if (_process.HasExited) + { + throw new Xunit.Sdk.XunitException( + $"Process exited before event {eventName}. {DiagnosticText()}"); + } + await Task.Delay(10, cancellationToken); + } + cancellationToken.ThrowIfCancellationRequested(); + throw new UnreachableException(); + } + + internal async Task WaitForLineAsync( + string marker, + CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + string? line = AllLines().FirstOrDefault(item => + item.Contains(marker, StringComparison.Ordinal)); + if (line is not null) + { + return line; + } + if (_process.HasExited) + { + throw new Xunit.Sdk.XunitException( + $"Process exited before output marker {marker}. {DiagnosticText()}"); + } + await Task.Delay(10, cancellationToken); + } + cancellationToken.ThrowIfCancellationRequested(); + throw new UnreachableException(); + } + + internal bool HasEvent(string eventName, string status) => JsonEvents().Any(item => + item.TryGetProperty("event", out JsonElement eventProperty) + && eventProperty.GetString() == eventName + && item.TryGetProperty("status", out JsonElement statusProperty) + && statusProperty.GetString() == status); + + internal List JsonEvents() + { + List items = []; + foreach (string line in _standardOutput.Concat(_standardError)) + { + try + { + using JsonDocument document = JsonDocument.Parse(line); + items.Add(document.RootElement.Clone()); + } + catch (JsonException) + { + } + } + return items; + } + + internal IEnumerable AllLines() => _standardOutput.Concat(_standardError); + + internal async Task WaitForExitAsync(CancellationToken cancellationToken) + { + await _process.WaitForExitAsync(cancellationToken); + return _process.ExitCode; + } + + internal string DiagnosticText() + { + string events = string.Join( + ',', + JsonEvents().Select(static item => + $"{item.GetProperty("event").GetString()}:{item.GetProperty("status").GetString()}")); + return $"stdoutLines={_standardOutput.Count}; stderrLines={_standardError.Count}; events=[{events}]"; + } + + public async ValueTask DisposeAsync() + { + bool cleanupTimedOut = false; + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + } + using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5)); + await _process.WaitForExitAsync(cleanup.Token); + } + catch (InvalidOperationException) + { + } + catch (OperationCanceledException) + { + cleanupTimedOut = !_process.HasExited; + if (cleanupTimedOut) + { + try + { + _process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + } + } + } + finally + { + _process.Dispose(); + } + if (cleanupTimedOut) + { + throw new Xunit.Sdk.XunitException("Child process did not exit within the cleanup deadline."); + } + } + + private static void Add(string? line, ConcurrentQueue destination) + { + if (!string.IsNullOrEmpty(line)) + { + destination.Enqueue(line); + } + } + } +}