397 lines
17 KiB
C#
397 lines
17 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using FinalFactory.Rendezvous.Contracts;
|
|
|
|
namespace FinalFactory.Rendezvous.TestClient;
|
|
|
|
internal enum TestClientMode
|
|
{
|
|
Host,
|
|
Browse,
|
|
Watch,
|
|
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<string, string> 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 bool ExerciseReconnect { get; init; }
|
|
internal bool ExerciseReset { 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 watch [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
|
|
|
|
Watch options:
|
|
--run-seconds NUMBER Stop after 1-86400 seconds
|
|
--exercise-reconnect Disconnect after an update and verify ordered replay
|
|
--exercise-reset Corrupt the snapshot cursor and verify reset/refresh
|
|
|
|
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<string, string> 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;
|
|
bool exerciseReconnect = false;
|
|
bool exerciseReset = false;
|
|
HashSet<string> 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"
|
|
or "--exercise-reconnect" or "--exercise-reset")
|
|
{
|
|
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";
|
|
exerciseReconnect |= option == "--exercise-reconnect";
|
|
exerciseReset |= option == "--exercise-reset";
|
|
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 is not (TestClientMode.Host or TestClientMode.Watch)
|
|
|| exitAfterEcho && mode != TestClientMode.Host
|
|
|| exerciseReconnect && mode != TestClientMode.Watch
|
|
|| exerciseReset && mode != TestClientMode.Watch
|
|
|| exerciseReconnect && exerciseReset
|
|
|| 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,
|
|
ExerciseReconnect = exerciseReconnect,
|
|
ExerciseReset = exerciseReset,
|
|
});
|
|
}
|
|
|
|
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<string, string> 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 '-');
|
|
}
|