Compare commits

..

2 Commits

Author SHA1 Message Date
KyuubiYoru 47382ddadc feat: add tenant provisioning and key lifecycle (#5)
quality-gate / quality (push) Successful in 50s
Closes #5
2026-07-16 05:13:35 +02:00
KyuubiYoru 69c8b2d2bc feat: freeze v1 transport contracts (#4)
quality-gate / quality (push) Successful in 51s
Closes #4
2026-07-16 04:52:38 +02:00
61 changed files with 6026 additions and 13 deletions
+3
View File
@@ -30,5 +30,8 @@ jobs:
- name: Build
run: dotnet build Rendezvous.slnx --configuration Release --no-restore
- name: Verify generated API contract
run: git diff --exit-code -- docs/api
- name: Test
run: dotnet test Rendezvous.slnx --configuration Release --no-build
+4
View File
@@ -5,7 +5,11 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="LiteNetLib" Version="2.1.4" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" />
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
+8
View File
@@ -79,6 +79,10 @@ Rendezvous is currently in its initial design and bootstrap stage. The first imp
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
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).
## Development
@@ -95,5 +99,9 @@ dotnet test Rendezvous.slnx --configuration Release --no-build
Run the bootstrap server with
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
the configured UDP mediator port; both stop through normal host cancellation.
The launch profile uses an ephemeral development-only signing key. Production
startup fails closed until externally supplied game policies and `env:` signing
key references resolve to valid key material; no reusable game secret is stored
in this repository or the public Client package.
The project dependency rules and supported runtime choices are documented in
[project and dependency boundaries](docs/architecture/project-boundaries.md).
File diff suppressed because it is too large Load Diff
+2
View File
@@ -8,6 +8,8 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
- [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md)
- [Game provisioning and signing-key lifecycle](../security/provisioning.md)
These decisions intentionally leave gameplay authority, player identity,
simulation, persistence, social features, skill matchmaking, and gameplay
+9 -3
View File
@@ -11,11 +11,13 @@ FinalFactory.Rendezvous.Server FinalFactory.Rendezvous.TestClient
- `Contracts` targets `netstandard2.1` and contains only versioned,
transport-neutral IDs and wire contracts. It cannot reference Server,
LiteNetLib, or Godot.
LiteNetLib, or Godot. Its only package is `System.Text.Json`, used for the
canonical cross-runtime JSON contract.
- `Client` targets `netstandard2.1`, references Contracts and the pinned
LiteNetLib package, and contains no Godot or Server dependency.
- `Server` targets .NET 10 LTS, references Contracts and LiteNetLib, and owns
HTTP hosting, UDP mediation, application policy, and ephemeral state.
- `Server` targets .NET 10 LTS, references Contracts, LiteNetLib, and the
first-party ASP.NET Core OpenAPI generator, and owns HTTP hosting, UDP
mediation, application policy, and ephemeral state.
- `TestClient` targets .NET 8, references only the public Client/Contracts seams
and LiteNetLib, and must never reach into Server internals.
- `Tests` target .NET 10 and may reference every project solely to verify public
@@ -32,6 +34,8 @@ engine, transport, or server dependency therefore fails the normal test gate.
.NET 8-or-later runtime used by current Godot 4 C# projects.
- TestClient runtime: .NET 8.
- LiteNetLib: 2.1.4, pinned centrally and restored from the lock files.
- Microsoft.OpenApi: patched 2.7.5 line, centrally pinned because the version
originally pulled by the .NET 10 generator is affected by CVE-2026-49451.
The repository uses central package versions, per-project lock files,
deterministic compilation, nullable reference types, warnings as errors, current
@@ -43,3 +47,5 @@ Primary compatibility references:
- [.NET support policy](https://dotnet.microsoft.com/en-us/platform/support/policy)
- [Godot stable C# prerequisites](https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/c_sharp_basics.html)
- [LiteNetLib 2.1.4 on NuGet](https://www.nuget.org/packages/LiteNetLib/2.1.4)
- [ASP.NET Core OpenAPI generation](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview?view=aspnetcore-10.0)
- [Microsoft.OpenApi security advisory](https://github.com/advisories/GHSA-v5pm-xwqc-g5wc)
+18
View File
@@ -0,0 +1,18 @@
# Versioned contracts
Tracking: #4
The v1 contract is defined by three artifacts that are reviewed and versioned
together:
- [HTTP v1 semantics](http-v1.md)
- [UDP v1 wire format](udp-v1.md)
- [Generated OpenAPI 3.1 document](../api/rendezvous-v1.json)
The public .NET types live in `FinalFactory.Rendezvous.Contracts`, target
`netstandard2.1`, and contain no Server, Godot, or LiteNetLib dependency. Golden
vectors and a public-API snapshot make accidental wire or source compatibility
changes fail the normal test gate.
Any incompatible change requires a new contract version. Additive JSON fields
may be introduced within v1 because v1 readers ignore unknown object members.
+105
View File
@@ -0,0 +1,105 @@
# HTTP contract v1
Tracking: #4
All production endpoints require HTTPS. JSON uses UTF-8, camel-case property
names, compact output, string-valued camel-case enums, and ISO 8601 timestamps.
Every request that contains a body carries `contractVersion: 1`; browse carries
the same value as a required query parameter.
## Compatibility and parsing
- Contract version matching is exact. Any value other than `1` fails with
`unsupportedContractVersion`; it is never guessed or downgraded.
- Gameplay protocol matching is exact. `buildVersion` is display and diagnostic
text only and never decides compatibility.
- Unknown JSON object properties are ignored so additive v1 responses remain
readable. Unknown enum names, numeric enum values, comments, trailing commas,
invalid identifier strings, and excessive nesting are rejected.
- Game, environment, and region IDs are lowercase URL-safe slugs. Listing,
lease, join-attempt, and mediation IDs are non-empty UUIDs serialized as JSON
strings.
- Clients must honor request cancellation. A disconnected or cancelled request
does not promise a response body; the server should stop work where safe.
## Endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/v1/sessions` | Register a session and create its renewable lease. |
| `POST` | `/v1/sessions/{listingId}/renew` | Renew the listing lease. |
| `PUT` | `/v1/sessions/{listingId}` | Replace mutable browser fields and capacity. |
| `DELETE` | `/v1/sessions/{listingId}` | Withdraw a listing. |
| `GET` | `/v1/sessions` | Browse compatible public sessions. |
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
| `GET` | `/health/live` | Report that the HTTP process is alive. |
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
shape reference for parameters, bodies, and responses. Contract-only endpoints
return `501` until their behavior is implemented by the subsequent directory,
lease, and join-orchestration issues.
Host polling sends its reusable lease credential in
`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
for mutation operations are carried in their request bodies. Public browser
responses contain no IP endpoints, lease tokens, punch capabilities, connection
tickets, player identifiers, or gameplay state.
## Idempotency, cursors, and retries
Registration and join creation require a caller-generated visible-ASCII
`idempotencyKey`. A repeat in the same authorization scope returns the original
result while the key is retained; reusing a key with a different payload fails
with `conflict`. Keys are opaque and must not contain credentials.
Cursors are opaque, endpoint-specific, short-lived values. A client may echo a
cursor only to the endpoint and filters that produced it. Invalid or expired
cursors fail with `invalidRequest`; clients restart browsing from the first page.
Renew, update, delete, and outcome reporting are safe to retry with the same
lease/attempt identity after a transport-level failure.
## Limits
Limits are measured after UTF-8 encoding where stated. Servers reject the
entire request rather than truncate values.
| Item | v1 limit |
| --- | ---: |
| HTTP request body | 16 KiB |
| Browser response body | 256 KiB |
| Browser page | 100 listings |
| Metadata document | 4 KiB, 32 keys |
| Metadata key / value | 64 / 256 UTF-8 bytes |
| Game / environment / region ID | 64 / 32 / 32 characters |
| Display name / build version | 128 / 64 UTF-8 bytes |
| Idempotency key | 64 visible ASCII characters |
| Cursor | 512 visible ASCII characters |
| Diagnostic code | 64 visible ASCII characters |
| Error message | 256 UTF-8 bytes |
| Reusable HTTP credential | 1,024 characters |
| Session capacity | 110,000 players |
## Error mapping
Errors use `ApiError` with a stable `code`, bounded safe `message`, optional
`correlationId`, and optional `retryAfterSeconds`. Messages are diagnostic and
must not be parsed. Secrets and raw credentials are never echoed.
| HTTP | Codes |
| ---: | --- |
| 400 | `invalidRequest`, `unsupportedContractVersion` |
| 401 | `authenticationRequired` |
| 403 | `forbidden` |
| 404 | `notFound` |
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` |
| 410 | `expired`, `staleHost` |
| 429 | `rateLimited` (with retry guidance when known) |
| 503 | `serviceUnavailable` (with retry guidance when known) |
| 500 | `internalError` |
Malformed input must receive the same bounded error family regardless of which
parser or validation stage rejected it.
+53
View File
@@ -0,0 +1,53 @@
# UDP presence contract v1
Tracking: #4
The UDP mediator accepts a single bounded presence envelope from a host or
client. It associates the authenticated mediation handle with the packet's
observed public source endpoint and the sender's reported local endpoint. It
does not carry gameplay packets.
All multi-byte integers use network byte order. UUID bytes use the canonical
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
mixed-endian layout returned by `Guid.ToByteArray()`.
## Datagram layout
| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 2 | Magic bytes `52 56` (`RV`). |
| 2 | 1 | Contract version, exactly `01`. |
| 3 | 1 | Message type: host presence `01`, client presence `02`. |
| 4 | 1 | Flags, exactly `00` in v1. |
| 5 | 16 | Non-empty mediation-handle UUID. |
| 21 | 1 | Address family: IPv4 `04`, IPv6 `06`. |
| 22 | 1 | Address length: `04` for IPv4, `10` for IPv6. |
| 23 | 4 or 16 | Raw local IP address bytes. |
| next | 2 | Local UDP port, 165535. |
| next | 1 | Capability length, 1192. |
| next | variable | ASCII base64url capability, without padding. |
No trailing bytes are permitted. The whole datagram is limited to 1,200 bytes,
well below common Internet path MTUs. The v1 capability limit is 192 characters,
which also keeps any value passed through LiteNetLib's 256-character NAT token
surface safely below that library boundary.
## Validation and failure behavior
Decoders return one stable failure category: oversized, truncated, invalid
magic, unsupported version, unknown message type, non-zero flags, invalid
handle, invalid address family, invalid address, invalid port, invalid
capability, or trailing data. Unknown versions and message types are rejected;
they are never interpreted as v1.
The address-family byte, encoded address length, and parsed address must agree.
The service derives the public endpoint from the UDP packet source and never
trusts a client-supplied public address. Reported local endpoints are candidates
only and grant no authority.
Capabilities are short-lived, single-purpose, scoped to one mediation handle,
and compared without exposing them in logs. A valid-looking packet does not
prove authorization until the capability is checked. Invalid packets receive
no UDP response, preventing the mediator from becoming an amplification oracle.
Replay, expiry, pairing, and rate-limit policy are defined by later mediator
issues; the v1 envelope deliberately leaves no unbounded or reflected payload.
+84
View File
@@ -0,0 +1,84 @@
# Game provisioning and signing-key lifecycle
Tracking: #5
Rendezvous treats game and environment scope as provisioned policy, not caller
input. Production starts only when it can build an enabled policy registry and
load at least one currently active signing key from an external secret provider.
Unknown and disabled scopes fail closed.
## Policy boundary
Each `GamePolicy` fixes the allowed:
- game/environment pair and regions;
- exact gameplay protocol versions;
- publisher trust and listing visibility modes;
- metadata keys, required keys, per-value limits, total bytes, and key count;
- listing, anonymous-host, and active-attempt quotas; and
- dedicated fallback feature policy.
Publisher authorization first authenticates a typed principal, then derives the
authoritative game/environment from that principal. Request fields are compared
for mismatch detection but never replace the authenticated scope. Dedicated
workloads, short-lived player-host grants, anonymous unlisted publishers, and
operators are distinct principal types. Operator credentials cannot be used as
publisher credentials, and anonymous publishers cannot escalate to public
visibility.
## Signed credentials
Signed principal credentials use the compact form
`rv1.<key-id>.<base64url-payload>.<base64url-HMAC-SHA256>`. The signed payload
contains version, issuer, audience, subject, principal kind, bounded scope,
issued/not-before/expiry times, and a random nonce. It contains no signing key,
reusable publisher secret, player identity, or gameplay state.
Validation is deliberately ordered and bounded:
1. enforce the v1 opaque-credential length and four-segment grammar;
2. resolve a known, non-revoked key in its verification window;
3. compare the HMAC in fixed time;
4. parse canonical bounded JSON;
5. require exact version, issuer, and audience;
6. enforce clock skew, expiry, key lifetime, principal kind, and scope shape.
Failures return typed internal reasons without echoing the credential. Logs and
metrics must record only allowlisted tenant/principal/result dimensions; token,
key, secret-reference value, and raw key material are excluded.
## Rotation and revocation
A key is bound either to operator credentials only or to allowed publisher
credential kinds for exactly one game/environment. The verifier checks this
authority after the signature, so even a compromised game grant issuer cannot
mint a valid cross-game or operator credential.
A key also has three times: `NotBefore`, `SignUntil`, and `VerifyUntil`. Issuance
picks the newest authorized non-revoked key inside its signing window. Older credentials continue
to verify until the old key's verification window ends, providing an explicit
overlap. After `VerifyUntil` they fail as retired. Configuration revocation and
runtime revocation both reject immediately. A configured revoked key retains
only its public key ID/lifecycle metadata and does not require retired secret
material to remain available.
Key IDs are non-secret base64url identifiers. Secret references are resolved
through `ISecretProvider`; production supports `env:<VARIABLE>` references and
the interface is replaceable by a deployment-specific vault/KMS adapter. The
committed development profile uses an in-memory random key identified by a
`development:ephemeral/...` reference. It never writes key material to disk and
all credentials become invalid when the process exits.
## Production configuration
`Rendezvous:Provisioning` supplies issuer, audience, clock skew, signing-key
descriptors, and game policies. A production key reference such as
`env:RENDEZVOUS_SIGNING_KEY_2026_01` expects that environment variable to hold at
least 32 random bytes encoded as base64. Missing, malformed, short, inactive, or
duplicate keys stop startup with a key-ID-only diagnostic. No game-wide secret
belongs in `appsettings`, source control, examples, the Client package, URLs,
responses, logs, metrics, exceptions, or diagnostic dumps.
Readiness becomes true only after provisioning and UDP startup both succeed.
OpenAPI generation uses a pinned build-only host and does not start listeners or
bypass provisioning in a deployed server process.
@@ -8,8 +8,71 @@
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TFI6OKYE1XZz4SGuTSH70c6SBdPpFktXsoa1gCxTr3mKrhmXirnvaS0tKz+J3ZWICEAmMpEGn59nO4ICtUpQXA=="
},
"System.Buffers": {
"type": "Transitive",
"resolved": "4.6.1",
"contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A=="
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA=="
},
"finalfactory.rendezvous.contracts": {
"type": "Project"
"type": "Project",
"dependencies": {
"System.Text.Json": "[10.0.10, )"
}
},
"System.Text.Json": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.10",
"System.Buffers": "4.6.1",
"System.IO.Pipelines": "10.0.10",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2",
"System.Text.Encodings.Web": "10.0.10",
"System.Threading.Tasks.Extensions": "4.6.3"
}
}
}
}
@@ -0,0 +1,75 @@
namespace FinalFactory.Rendezvous.Contracts;
public enum RendezvousErrorCode
{
None = 0,
InvalidRequest = 1,
UnsupportedContractVersion = 2,
IncompatibleProtocol = 3,
AuthenticationRequired = 4,
Forbidden = 5,
NotFound = 6,
Conflict = 7,
RateLimited = 8,
StaleHost = 9,
Expired = 10,
ReplayRejected = 11,
CapacityExceeded = 12,
ServiceUnavailable = 13,
InternalError = 14,
}
public enum ListingVisibility
{
Public = 1,
Unlisted = 2,
}
public enum PublisherTrustMode
{
ManagedDedicated = 1,
PlayerGrant = 2,
AnonymousUnlisted = 3,
}
public enum AddressFamilyKind
{
Ipv4 = 4,
Ipv6 = 6,
}
public enum ConnectionOutcomeKind
{
Connected = 1,
Cancelled = 2,
TimedOut = 3,
IncompatibleProtocol = 4,
StaleHost = 5,
ServiceRejected = 6,
HostRejected = 7,
TransportFailed = 8,
FallbackOffered = 9,
}
public enum UdpPresenceMessageType : byte
{
HostPresence = 1,
ClientPresence = 2,
}
public enum UdpDecodeError
{
None = 0,
DatagramTooLarge = 1,
Truncated = 2,
InvalidMagic = 3,
UnsupportedVersion = 4,
UnknownMessageType = 5,
InvalidFlags = 6,
InvalidHandle = 7,
InvalidAddressFamily = 8,
InvalidAddress = 9,
InvalidPort = 10,
InvalidCapability = 11,
TrailingData = 12,
}
@@ -0,0 +1,28 @@
namespace FinalFactory.Rendezvous.Contracts;
public static class ContractLimits
{
public const int ContractVersion = 1;
public const int HttpRequestMaxBytes = 16 * 1024;
public const int BrowserResponseMaxBytes = 256 * 1024;
public const int UdpDatagramMaxBytes = 1_200;
public const int MetadataMaxBytes = 4 * 1024;
public const int MetadataMaxKeys = 32;
public const int MetadataKeyMaxBytes = 64;
public const int MetadataValueMaxBytes = 256;
public const int BrowserPageMaxItems = 100;
public const int GameIdMaxCharacters = 64;
public const int EnvironmentIdMaxCharacters = 32;
public const int RegionIdMaxCharacters = 32;
public const int DisplayNameMaxBytes = 128;
public const int BuildVersionMaxBytes = 64;
public const int IdempotencyKeyMaxCharacters = 64;
public const int CursorMaxCharacters = 512;
public const int DiagnosticCodeMaxCharacters = 64;
public const int ErrorMessageMaxBytes = 256;
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
public const int UdpCapabilityMaxCharacters = 192;
public const int ConnectionTicketMaxCharacters = 192;
public const int LiteNetLibNatTokenMaxCharacters = 256;
public const int SessionCapacityMaxPlayers = 10_000;
}
@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class SessionCapacity
{
[JsonRequired]
public int CurrentPlayers { get; set; }
[JsonRequired]
public int MaximumPlayers { get; set; }
}
public sealed class NetworkEndpoint
{
[JsonRequired]
public AddressFamilyKind AddressFamily { get; set; }
[JsonRequired]
public string Address { get; set; } = string.Empty;
[JsonRequired]
public int Port { get; set; }
}
public sealed class ApiError
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public RendezvousErrorCode Code { get; set; }
[JsonRequired]
public string Message { get; set; } = string.Empty;
public string? CorrelationId { get; set; }
public int? RetryAfterSeconds { get; set; }
}
public sealed class HealthResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string Status { get; set; } = string.Empty;
}
@@ -0,0 +1,134 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
namespace FinalFactory.Rendezvous.Contracts;
public static class ContractValidation
{
private const string CapabilityAlphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
public static RendezvousErrorCode ValidateContractVersion(int contractVersion) =>
contractVersion == ContractLimits.ContractVersion
? RendezvousErrorCode.None
: RendezvousErrorCode.UnsupportedContractVersion;
public static bool AreProtocolsCompatible(uint requested, uint offered) => requested == offered;
public static bool IsHttpRequestSizeValid(int byteCount) =>
byteCount is >= 0 and <= ContractLimits.HttpRequestMaxBytes;
public static bool IsBrowserResponseSizeValid(int byteCount) =>
byteCount is >= 0 and <= ContractLimits.BrowserResponseMaxBytes;
public static bool IsUtf8LengthWithin(string? value, int maximumBytes)
{
if (maximumBytes < 0)
{
throw new ArgumentOutOfRangeException(nameof(maximumBytes));
}
return value is not null && Encoding.UTF8.GetByteCount(value) <= maximumBytes;
}
public static bool IsPageSizeValid(int pageSize) =>
pageSize is >= 1 and <= ContractLimits.BrowserPageMaxItems;
public static bool IsIdempotencyKeyValid(string? value) =>
IsVisibleAsciiWithin(value, ContractLimits.IdempotencyKeyMaxCharacters);
public static bool IsCursorValid(string? value) =>
value is null || IsVisibleAsciiWithin(value, ContractLimits.CursorMaxCharacters);
public static bool IsDiagnosticCodeValid(string? value) =>
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
public static bool IsBuildVersionValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
public static bool IsDisplayNameValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
public static bool IsOpaqueHttpCredentialValid(string? value) =>
value is not null
&& value.Length is > 0 and <= ContractLimits.OpaqueHttpCredentialMaxCharacters;
public static bool IsCapacityValid(SessionCapacity? capacity) =>
capacity is not null
&& capacity.MaximumPlayers is >= 1 and <= ContractLimits.SessionCapacityMaxPlayers
&& capacity.CurrentPlayers >= 0
&& capacity.CurrentPlayers <= capacity.MaximumPlayers;
public static bool IsCapabilityValid(string? capability) =>
IsBase64UrlValueValid(capability, ContractLimits.UdpCapabilityMaxCharacters);
public static bool IsConnectionTicketValid(string? ticket) =>
IsBase64UrlValueValid(ticket, ContractLimits.ConnectionTicketMaxCharacters);
public static bool IsNetworkEndpointValid(NetworkEndpoint? endpoint)
{
if (endpoint is null
|| endpoint.Port is < 1 or > ushort.MaxValue
|| !IPAddress.TryParse(endpoint.Address, out IPAddress? address))
{
return false;
}
return endpoint.AddressFamily switch
{
AddressFamilyKind.Ipv4 => address.AddressFamily == AddressFamily.InterNetwork,
AddressFamilyKind.Ipv6 => address.AddressFamily == AddressFamily.InterNetworkV6,
_ => false,
};
}
public static bool IsMetadataValid(IReadOnlyDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count > ContractLimits.MetadataMaxKeys)
{
return false;
}
foreach (KeyValuePair<string, string> item in metadata)
{
if (string.IsNullOrWhiteSpace(item.Key)
|| !IsUtf8LengthWithin(item.Key, ContractLimits.MetadataKeyMaxBytes)
|| !IsUtf8LengthWithin(item.Value, ContractLimits.MetadataValueMaxBytes))
{
return false;
}
}
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(metadata, ContractJson.Options);
return encoded.Length <= ContractLimits.MetadataMaxBytes;
}
internal static bool IsSlug(string? value, int maximumCharacters)
{
if (string.IsNullOrEmpty(value)
|| value.Length > maximumCharacters
|| value[0] is < 'a' or > 'z')
{
return false;
}
return value.All(static character =>
character is >= 'a' and <= 'z'
or >= '0' and <= '9'
or '-');
}
private static bool IsBase64UrlValueValid(string? value, int maximumCharacters) =>
value is not null
&& value.Length is > 0
&& value.Length <= maximumCharacters
&& value.All(static character => CapabilityAlphabet.Contains(character));
private static bool IsVisibleAsciiWithin(string? value, int maximumCharacters) =>
value is not null
&& value.Length is > 0
&& value.Length <= maximumCharacters
&& value.All(static character => character is >= '!' and <= '~');
}
@@ -7,4 +7,7 @@
<PackageId>FinalFactory.Rendezvous.Contracts</PackageId>
<Description>Versioned transport-neutral contracts for Final Factory Rendezvous.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" />
</ItemGroup>
</Project>
@@ -0,0 +1,92 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class CreateJoinAttemptRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string IdempotencyKey { get; set; } = string.Empty;
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public SessionListingId ListingId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
}
public sealed class CreateJoinAttemptResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public JoinAttemptId AttemptId { get; set; }
[JsonRequired]
public MediationHandle MediationHandle { get; set; }
[JsonRequired]
public string ClientPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
public NetworkEndpoint? DedicatedFallback { get; set; }
}
public sealed class HostJoinAttempt
{
[JsonRequired]
public JoinAttemptId AttemptId { get; set; }
[JsonRequired]
public MediationHandle MediationHandle { get; set; }
[JsonRequired]
public string HostPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
public sealed class BrowseHostJoinAttemptsResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public List<HostJoinAttempt> Items { get; set; } = [];
public string? NextCursor { get; set; }
}
public sealed class ReportConnectionOutcomeRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public ConnectionOutcomeKind Outcome { get; set; }
[JsonRequired]
public int ElapsedMilliseconds { get; set; }
public string? DiagnosticCode { get; set; }
}
public sealed class ReportConnectionOutcomeResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public bool Accepted { get; set; }
}
@@ -0,0 +1,189 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class SessionListing
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionListingId ListingId { get; set; }
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public RegionId RegionId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
[JsonRequired]
public string DisplayName { get; set; } = string.Empty;
[JsonRequired]
public ListingVisibility Visibility { get; set; }
[JsonRequired]
public PublisherTrustMode PublisherTrustMode { get; set; }
[JsonRequired]
public SessionCapacity Capacity { get; set; } = new();
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
}
public sealed class RegisterSessionRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string IdempotencyKey { get; set; } = string.Empty;
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public RegionId RegionId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
[JsonRequired]
public string DisplayName { get; set; } = string.Empty;
[JsonRequired]
public ListingVisibility Visibility { get; set; }
[JsonRequired]
public SessionCapacity Capacity { get; set; } = new();
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
}
public sealed class RegisterSessionResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionListingId ListingId { get; set; }
[JsonRequired]
public LeaseId LeaseId { get; set; }
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
[JsonRequired]
public MediationHandle HostPresenceHandle { get; set; }
[JsonRequired]
public string HostPresenceCapability { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
public sealed class RenewLeaseRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
}
public sealed class RenewLeaseResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
public sealed class UpdateSessionRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
[JsonRequired]
public string DisplayName { get; set; } = string.Empty;
[JsonRequired]
public SessionCapacity Capacity { get; set; } = new();
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
}
public sealed class DeleteSessionRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
}
public sealed class BrowseSessionsRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
public RegionId? RegionId { get; set; }
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
public string? Cursor { get; set; }
}
public sealed class BrowseSessionsResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public List<SessionListing> Items { get; set; } = [];
public string? NextCursor { get; set; }
}
public sealed class GetSessionResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionListing Session { get; set; } = new();
}
@@ -0,0 +1,112 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
[JsonConverter(typeof(SessionListingIdJsonConverter))]
public readonly struct SessionListingId : IEquatable<SessionListingId>
{
public SessionListingId(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out SessionListingId id) =>
GuidIdentifier.TryParse(value, static guid => new SessionListingId(guid), out id);
public bool Equals(SessionListingId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is SessionListingId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(SessionListingId left, SessionListingId right) => left.Equals(right);
public static bool operator !=(SessionListingId left, SessionListingId right) => !left.Equals(right);
}
[JsonConverter(typeof(LeaseIdJsonConverter))]
public readonly struct LeaseId : IEquatable<LeaseId>
{
public LeaseId(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out LeaseId id) =>
GuidIdentifier.TryParse(value, static guid => new LeaseId(guid), out id);
public bool Equals(LeaseId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is LeaseId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(LeaseId left, LeaseId right) => left.Equals(right);
public static bool operator !=(LeaseId left, LeaseId right) => !left.Equals(right);
}
[JsonConverter(typeof(JoinAttemptIdJsonConverter))]
public readonly struct JoinAttemptId : IEquatable<JoinAttemptId>
{
public JoinAttemptId(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out JoinAttemptId id) =>
GuidIdentifier.TryParse(value, static guid => new JoinAttemptId(guid), out id);
public bool Equals(JoinAttemptId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is JoinAttemptId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(JoinAttemptId left, JoinAttemptId right) => left.Equals(right);
public static bool operator !=(JoinAttemptId left, JoinAttemptId right) => !left.Equals(right);
}
[JsonConverter(typeof(MediationHandleJsonConverter))]
public readonly struct MediationHandle : IEquatable<MediationHandle>
{
public MediationHandle(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out MediationHandle id) =>
GuidIdentifier.TryParse(value, static guid => new MediationHandle(guid), out id);
public bool Equals(MediationHandle other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is MediationHandle other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(MediationHandle left, MediationHandle right) => left.Equals(right);
public static bool operator !=(MediationHandle left, MediationHandle right) => !left.Equals(right);
}
internal static class GuidIdentifier
{
public static Guid RequireNonEmpty(Guid value, string parameterName) =>
value != Guid.Empty
? value
: throw new ArgumentException("Opaque identifiers cannot be empty.", parameterName);
public static bool TryParse<TIdentifier>(
string? value,
Func<Guid, TIdentifier> factory,
out TIdentifier identifier)
{
if (Guid.TryParseExact(value, "D", out Guid guid) && guid != Guid.Empty)
{
identifier = factory(guid);
return true;
}
identifier = default!;
return false;
}
}
internal abstract class GuidIdentifierJsonConverter<TIdentifier> :
StringIdentifierJsonConverter<TIdentifier>
{
protected sealed override string Format(TIdentifier value) => value?.ToString() ?? string.Empty;
}
internal sealed class SessionListingIdJsonConverter : GuidIdentifierJsonConverter<SessionListingId>
{
protected override SessionListingId Parse(string value) => new(Guid.ParseExact(value, "D"));
}
internal sealed class LeaseIdJsonConverter : GuidIdentifierJsonConverter<LeaseId>
{
protected override LeaseId Parse(string value) => new(Guid.ParseExact(value, "D"));
}
internal sealed class JoinAttemptIdJsonConverter : GuidIdentifierJsonConverter<JoinAttemptId>
{
protected override JoinAttemptId Parse(string value) => new(Guid.ParseExact(value, "D"));
}
internal sealed class MediationHandleJsonConverter : GuidIdentifierJsonConverter<MediationHandle>
{
protected override MediationHandle Parse(string value) => new(Guid.ParseExact(value, "D"));
}
@@ -0,0 +1,126 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
[JsonConverter(typeof(GameIdJsonConverter))]
public readonly struct GameId : IEquatable<GameId>
{
public GameId(string value)
{
if (!ContractValidation.IsSlug(value, ContractLimits.GameIdMaxCharacters))
{
throw new ArgumentException("Game IDs must be lowercase URL-safe slugs.", nameof(value));
}
Value = value;
}
public string Value { get; }
public static bool TryParse(string? value, out GameId gameId)
{
if (ContractValidation.IsSlug(value, ContractLimits.GameIdMaxCharacters))
{
gameId = new GameId(value!);
return true;
}
gameId = default;
return false;
}
public bool Equals(GameId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is GameId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value ?? string.Empty;
public static bool operator ==(GameId left, GameId right) => left.Equals(right);
public static bool operator !=(GameId left, GameId right) => !left.Equals(right);
}
[JsonConverter(typeof(EnvironmentIdJsonConverter))]
public readonly struct EnvironmentId : IEquatable<EnvironmentId>
{
public EnvironmentId(string value)
{
if (!ContractValidation.IsSlug(value, ContractLimits.EnvironmentIdMaxCharacters))
{
throw new ArgumentException("Environment IDs must be lowercase URL-safe slugs.", nameof(value));
}
Value = value;
}
public string Value { get; }
public static bool TryParse(string? value, out EnvironmentId environmentId)
{
if (ContractValidation.IsSlug(value, ContractLimits.EnvironmentIdMaxCharacters))
{
environmentId = new EnvironmentId(value!);
return true;
}
environmentId = default;
return false;
}
public bool Equals(EnvironmentId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is EnvironmentId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value ?? string.Empty;
public static bool operator ==(EnvironmentId left, EnvironmentId right) => left.Equals(right);
public static bool operator !=(EnvironmentId left, EnvironmentId right) => !left.Equals(right);
}
[JsonConverter(typeof(RegionIdJsonConverter))]
public readonly struct RegionId : IEquatable<RegionId>
{
public RegionId(string value)
{
if (!ContractValidation.IsSlug(value, ContractLimits.RegionIdMaxCharacters))
{
throw new ArgumentException("Region IDs must be lowercase URL-safe slugs.", nameof(value));
}
Value = value;
}
public string Value { get; }
public static bool TryParse(string? value, out RegionId regionId)
{
if (ContractValidation.IsSlug(value, ContractLimits.RegionIdMaxCharacters))
{
regionId = new RegionId(value!);
return true;
}
regionId = default;
return false;
}
public bool Equals(RegionId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is RegionId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value ?? string.Empty;
public static bool operator ==(RegionId left, RegionId right) => left.Equals(right);
public static bool operator !=(RegionId left, RegionId right) => !left.Equals(right);
}
internal sealed class GameIdJsonConverter : StringIdentifierJsonConverter<GameId>
{
protected override GameId Parse(string value) => new(value);
protected override string Format(GameId value) => value.Value;
}
internal sealed class EnvironmentIdJsonConverter : StringIdentifierJsonConverter<EnvironmentId>
{
protected override EnvironmentId Parse(string value) => new(value);
protected override string Format(EnvironmentId value) => value.Value;
}
internal sealed class RegionIdJsonConverter : StringIdentifierJsonConverter<RegionId>
{
protected override RegionId Parse(string value) => new(value);
protected override string Format(RegionId value) => value.Value;
}
@@ -0,0 +1,37 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
internal abstract class StringIdentifierJsonConverter<TIdentifier> : JsonConverter<TIdentifier>
{
public sealed override TIdentifier Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"{typeof(TIdentifier).Name} must be a JSON string.");
}
string value = reader.GetString() ?? string.Empty;
try
{
return Parse(value);
}
catch (Exception exception) when (exception is ArgumentException or FormatException)
{
throw new JsonException($"Invalid {typeof(TIdentifier).Name}.", exception);
}
}
public sealed override void Write(
Utf8JsonWriter writer,
TIdentifier value,
JsonSerializerOptions options) => writer.WriteStringValue(Format(value));
protected abstract TIdentifier Parse(string value);
protected abstract string Format(TIdentifier value);
}
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public static class ContractJson
{
private static readonly JsonSerializerOptions SharedOptions = CreateReadOnlyOptions();
public static JsonSerializerOptions Options => SharedOptions;
public static JsonSerializerOptions CreateOptions()
{
JsonSerializerOptions options = new(JsonSerializerDefaults.Web);
Configure(options);
return options;
}
public static void Configure(JsonSerializerOptions options)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
options.AllowTrailingCommas = false;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.MaxDepth = 8;
options.NumberHandling = JsonNumberHandling.Strict;
options.PropertyNameCaseInsensitive = false;
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.ReadCommentHandling = JsonCommentHandling.Disallow;
options.UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip;
options.WriteIndented = false;
if (!options.Converters.OfType<JsonStringEnumConverter>().Any())
{
options.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
}
}
private static JsonSerializerOptions CreateReadOnlyOptions()
{
JsonSerializerOptions options = CreateOptions();
options.MakeReadOnly(populateMissingResolver: true);
return options;
}
}
@@ -0,0 +1,12 @@
namespace FinalFactory.Rendezvous.Contracts;
public sealed class PresenceDatagram
{
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
public UdpPresenceMessageType MessageType { get; set; }
public MediationHandle MediationHandle { get; set; }
public AddressFamilyKind AddressFamily { get; set; }
public string LocalAddress { get; set; } = string.Empty;
public int LocalPort { get; set; }
public string Capability { get; set; } = string.Empty;
}
@@ -0,0 +1,281 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace FinalFactory.Rendezvous.Contracts;
public static class RendezvousUdpCodec
{
public const byte MagicFirst = 0x52;
public const byte MagicSecond = 0x56;
public const byte FlagsNone = 0;
private const int FixedPrefixLength = 23;
private const int FixedSuffixLength = 3;
public static byte[] Encode(PresenceDatagram datagram)
{
if (datagram is null)
{
throw new ArgumentNullException(nameof(datagram));
}
if (ContractValidation.ValidateContractVersion(datagram.ContractVersion)
!= RendezvousErrorCode.None)
{
throw new ArgumentException("The UDP contract version is unsupported.", nameof(datagram));
}
if (datagram.MessageType is not UdpPresenceMessageType.HostPresence
and not UdpPresenceMessageType.ClientPresence)
{
throw new ArgumentException("The UDP presence message type is unknown.", nameof(datagram));
}
if (datagram.MediationHandle.Value == Guid.Empty)
{
throw new ArgumentException("The mediation handle cannot be empty.", nameof(datagram));
}
if (!TryGetAddressBytes(datagram.LocalAddress, datagram.AddressFamily, out byte[] addressBytes))
{
throw new ArgumentException("The local address does not match its address family.", nameof(datagram));
}
if (datagram.LocalPort is < 1 or > ushort.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(datagram), "The local port must be between 1 and 65535.");
}
if (!ContractValidation.IsCapabilityValid(datagram.Capability))
{
throw new ArgumentException("The UDP capability is invalid.", nameof(datagram));
}
byte[] capabilityBytes = Encoding.ASCII.GetBytes(datagram.Capability);
int encodedLength = FixedPrefixLength + addressBytes.Length + FixedSuffixLength
+ capabilityBytes.Length;
if (encodedLength > ContractLimits.UdpDatagramMaxBytes)
{
throw new ArgumentException("The encoded UDP datagram exceeds its size limit.", nameof(datagram));
}
byte[] encoded = new byte[encodedLength];
int offset = 0;
encoded[offset++] = MagicFirst;
encoded[offset++] = MagicSecond;
encoded[offset++] = checked((byte)datagram.ContractVersion);
encoded[offset++] = (byte)datagram.MessageType;
encoded[offset++] = FlagsNone;
WriteGuid(datagram.MediationHandle.Value, encoded, offset);
offset += 16;
encoded[offset++] = (byte)datagram.AddressFamily;
encoded[offset++] = checked((byte)addressBytes.Length);
addressBytes.CopyTo(encoded, offset);
offset += addressBytes.Length;
encoded[offset++] = checked((byte)(datagram.LocalPort >> 8));
encoded[offset++] = checked((byte)(datagram.LocalPort & 0xff));
encoded[offset++] = checked((byte)capabilityBytes.Length);
capabilityBytes.CopyTo(encoded, offset);
return encoded;
}
public static bool TryDecode(
ReadOnlySpan<byte> encoded,
out PresenceDatagram? datagram,
out UdpDecodeError error)
{
datagram = null;
error = UdpDecodeError.None;
if (encoded.Length > ContractLimits.UdpDatagramMaxBytes)
{
error = UdpDecodeError.DatagramTooLarge;
return false;
}
if (encoded.Length < FixedPrefixLength)
{
error = UdpDecodeError.Truncated;
return false;
}
int offset = 0;
if (encoded[offset++] != MagicFirst || encoded[offset++] != MagicSecond)
{
error = UdpDecodeError.InvalidMagic;
return false;
}
int version = encoded[offset++];
if (ContractValidation.ValidateContractVersion(version) != RendezvousErrorCode.None)
{
error = UdpDecodeError.UnsupportedVersion;
return false;
}
UdpPresenceMessageType messageType = (UdpPresenceMessageType)encoded[offset++];
if (messageType is not UdpPresenceMessageType.HostPresence
and not UdpPresenceMessageType.ClientPresence)
{
error = UdpDecodeError.UnknownMessageType;
return false;
}
if (encoded[offset++] != FlagsNone)
{
error = UdpDecodeError.InvalidFlags;
return false;
}
if (!TryReadGuid(encoded.Slice(offset, 16), out Guid handle)
|| handle == Guid.Empty)
{
error = UdpDecodeError.InvalidHandle;
return false;
}
offset += 16;
AddressFamilyKind addressFamily = (AddressFamilyKind)encoded[offset++];
int expectedAddressLength = addressFamily switch
{
AddressFamilyKind.Ipv4 => 4,
AddressFamilyKind.Ipv6 => 16,
_ => 0,
};
if (expectedAddressLength == 0)
{
error = UdpDecodeError.InvalidAddressFamily;
return false;
}
int addressLength = encoded[offset++];
if (addressLength != expectedAddressLength)
{
error = UdpDecodeError.InvalidAddress;
return false;
}
if (encoded.Length < offset + addressLength + FixedSuffixLength)
{
error = UdpDecodeError.Truncated;
return false;
}
string address;
try
{
address = new IPAddress(encoded.Slice(offset, addressLength).ToArray()).ToString();
}
catch (ArgumentException)
{
error = UdpDecodeError.InvalidAddress;
return false;
}
offset += addressLength;
int port = (encoded[offset++] << 8) | encoded[offset++];
if (port == 0)
{
error = UdpDecodeError.InvalidPort;
return false;
}
int capabilityLength = encoded[offset++];
if (capabilityLength == 0 || capabilityLength > ContractLimits.UdpCapabilityMaxCharacters)
{
error = UdpDecodeError.InvalidCapability;
return false;
}
if (encoded.Length < offset + capabilityLength)
{
error = UdpDecodeError.Truncated;
return false;
}
if (encoded.Length > offset + capabilityLength)
{
error = UdpDecodeError.TrailingData;
return false;
}
string capability = Encoding.ASCII.GetString(encoded.Slice(offset, capabilityLength).ToArray());
if (!ContractValidation.IsCapabilityValid(capability))
{
error = UdpDecodeError.InvalidCapability;
return false;
}
datagram = new PresenceDatagram
{
ContractVersion = version,
MessageType = messageType,
MediationHandle = new MediationHandle(handle),
AddressFamily = addressFamily,
LocalAddress = address,
LocalPort = port,
Capability = capability,
};
return true;
}
private static bool TryGetAddressBytes(
string value,
AddressFamilyKind addressFamily,
out byte[] addressBytes)
{
addressBytes = [];
if (!IPAddress.TryParse(value, out IPAddress? address))
{
return false;
}
bool familyMatches = addressFamily switch
{
AddressFamilyKind.Ipv4 => address.AddressFamily == AddressFamily.InterNetwork,
AddressFamilyKind.Ipv6 => address.AddressFamily == AddressFamily.InterNetworkV6,
_ => false,
};
if (!familyMatches)
{
return false;
}
addressBytes = address.GetAddressBytes();
return true;
}
private static void WriteGuid(Guid value, byte[] destination, int offset)
{
string hexadecimal = value.ToString("N");
for (int index = 0; index < 16; index++)
{
int high = ParseHexadecimal(hexadecimal[index * 2]);
int low = ParseHexadecimal(hexadecimal[(index * 2) + 1]);
destination[offset + index] = checked((byte)((high << 4) | low));
}
}
private static bool TryReadGuid(ReadOnlySpan<byte> encoded, out Guid value)
{
char[] hexadecimal = new char[32];
for (int index = 0; index < encoded.Length; index++)
{
hexadecimal[index * 2] = FormatHexadecimal(encoded[index] >> 4);
hexadecimal[(index * 2) + 1] = FormatHexadecimal(encoded[index] & 0x0f);
}
return Guid.TryParseExact(new string(hexadecimal), "N", out value);
}
private static int ParseHexadecimal(char value) => value switch
{
>= '0' and <= '9' => value - '0',
>= 'a' and <= 'f' => value - 'a' + 10,
_ => throw new FormatException("A GUID contained a non-hexadecimal character."),
};
private static char FormatHexadecimal(int value) =>
(char)(value < 10 ? '0' + value : 'a' + value - 10);
}
@@ -1,6 +1,67 @@
{
"version": 2,
"dependencies": {
".NETStandard,Version=v2.1": {}
".NETStandard,Version=v2.1": {
"System.Text.Json": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.10",
"System.Buffers": "4.6.1",
"System.IO.Pipelines": "10.0.10",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2",
"System.Text.Encodings.Web": "10.0.10",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TFI6OKYE1XZz4SGuTSH70c6SBdPpFktXsoa1gCxTr3mKrhmXirnvaS0tKz+J3ZWICEAmMpEGn59nO4ICtUpQXA=="
},
"System.Buffers": {
"type": "Transitive",
"resolved": "4.6.1",
"contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A=="
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA=="
}
}
}
}
@@ -4,9 +4,14 @@
<AssemblyName>FinalFactory.Rendezvous.Server</AssemblyName>
<RootNamespace>FinalFactory.Rendezvous.Server</RootNamespace>
<IsPackable>false</IsPackable>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../docs/api</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--document-name v1 --file-name rendezvous-v1 --openapi-version OpenApi3_1</OpenApiGenerateDocumentsOptions>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" PrivateAssets="all" />
</ItemGroup>
</Project>
@@ -0,0 +1,112 @@
using FinalFactory.Rendezvous.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace FinalFactory.Rendezvous.Server.Http;
internal static class ContractEndpoints
{
private const int NotImplementedStatus = StatusCodes.Status501NotImplemented;
public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder sessions = endpoints.MapGroup("/v1/sessions").WithTags("Sessions");
sessions.MapPost("/", RegisterSession)
.Accepts<RegisterSessionRequest>("application/json")
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(NotImplementedStatus)
.WithName("RegisterSession");
sessions.MapPost("/{listingId}/renew", RenewLease)
.Accepts<RenewLeaseRequest>("application/json")
.Produces<RenewLeaseResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("RenewSessionLease");
sessions.MapPut("/{listingId}", UpdateSession)
.Accepts<UpdateSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(NotImplementedStatus)
.WithName("UpdateSession");
sessions.MapDelete("/{listingId}", DeleteSession)
.Accepts<DeleteSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(NotImplementedStatus)
.WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("BrowseHostJoinAttempts");
RouteGroupBuilder attempts = endpoints
.MapGroup("/v1/join-attempts")
.WithTags("Join attempts");
attempts.MapPost("/", CreateJoinAttempt)
.Accepts<CreateJoinAttemptRequest>("application/json")
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(NotImplementedStatus)
.WithName("CreateJoinAttempt");
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("ReportConnectionOutcome");
return endpoints;
}
private static IResult RegisterSession([FromBody] RegisterSessionRequest request) =>
NotImplemented();
private static IResult RenewLease(
SessionListingId listingId,
[FromBody] RenewLeaseRequest request) => NotImplemented();
private static IResult UpdateSession(
SessionListingId listingId,
[FromBody] UpdateSessionRequest request) => NotImplemented();
private static IResult DeleteSession(
SessionListingId listingId,
[FromBody] DeleteSessionRequest request) => NotImplemented();
private static IResult BrowseSessions(
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromQuery] string? regionId,
[FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented();
private static IResult GetSession(SessionListingId listingId) => NotImplemented();
private static IResult BrowseHostJoinAttempts(
SessionListingId listingId,
[FromQuery] int contractVersion,
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
[FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented();
private static IResult CreateJoinAttempt([FromBody] CreateJoinAttemptRequest request) =>
NotImplemented();
private static IResult ReportConnectionOutcome(
JoinAttemptId attemptId,
[FromBody] ReportConnectionOutcomeRequest request) => NotImplemented();
private static IResult NotImplemented() => Results.Json(
new ApiError
{
Code = RendezvousErrorCode.ServiceUnavailable,
Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.",
},
ContractJson.Options,
statusCode: NotImplementedStatus);
}
+75 -4
View File
@@ -1,7 +1,62 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.OpenApi;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
bool isOpenApiGeneration = string.Equals(
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
"GetDocument.Insider",
StringComparison.Ordinal);
builder.Services.AddOpenApi("v1", static options =>
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
{
Type type = context.JsonTypeInfo.Type;
if (type == typeof(GameId)
|| type == typeof(EnvironmentId)
|| type == typeof(RegionId))
{
schema.Type = JsonSchemaType.String;
}
else if (type == typeof(SessionListingId)
|| type == typeof(LeaseId)
|| type == typeof(JoinAttemptId)
|| type == typeof(MediationHandle))
{
schema.Type = JsonSchemaType.String;
schema.Format = "uuid";
}
return Task.CompletedTask;
}));
builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions));
if (isOpenApiGeneration)
{
builder.Services.AddSingleton(new ProvisioningReadiness(false));
}
else
{
ProvisioningOptions provisioningOptions = builder.Configuration
.GetSection(ProvisioningOptions.SectionName)
.Get<ProvisioningOptions>() ?? new ProvisioningOptions();
ISecretProvider secretProvider = builder.Environment.IsDevelopment()
? new EphemeralDevelopmentSecretProvider()
: new EnvironmentSecretProvider();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
provisioningOptions,
secretProvider,
DateTimeOffset.UtcNow);
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
builder.Services.AddSingleton(new ProvisioningReadiness(true));
}
builder.Services
.AddOptions<UdpMediatorOptions>()
@@ -12,16 +67,32 @@ builder.Services
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
.ValidateOnStart();
builder.Services.AddSingleton<UdpMediatorService>();
builder.Services.AddHostedService(static services => services.GetRequiredService<UdpMediatorService>());
if (!isOpenApiGeneration)
{
builder.Services.AddHostedService(static services =>
services.GetRequiredService<UdpMediatorService>());
}
WebApplication app = builder.Build();
app.MapGet("/health/live", static () => Results.Ok(new { status = "live" }));
app.MapOpenApi();
app.MapRendezvousContractEndpoints();
app.MapGet(
"/health/live",
static () => Results.Ok(new HealthResponse { Status = "live" }))
.Produces<HealthResponse>()
.WithName("GetLiveness")
.WithTags("Health");
app.MapGet(
"/health/ready",
static (UdpMediatorService mediator) => mediator.LocalEndpoint is null
static (UdpMediatorService mediator, ProvisioningReadiness provisioning) =>
mediator.LocalEndpoint is null || !provisioning.IsReady
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
: Results.Ok(new { status = "ready" }));
: Results.Ok(new HealthResponse { Status = "ready" }))
.Produces<HealthResponse>()
.Produces(StatusCodes.Status503ServiceUnavailable)
.WithName("GetReadiness")
.WithTags("Health");
await app.RunAsync();
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"development": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://127.0.0.1:5096",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,80 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class GamePolicy
{
private readonly HashSet<uint> _protocolVersions;
private readonly HashSet<RegionId> _regions;
private readonly HashSet<ListingVisibility> _visibilityModes;
private readonly HashSet<PublisherTrustMode> _publisherTrustModes;
private readonly Dictionary<string, int> _metadataValueMaxBytes;
private readonly HashSet<string> _requiredMetadataKeys;
public GamePolicy(GamePolicyOptions options)
{
GameId = new GameId(options.GameId);
EnvironmentId = new EnvironmentId(options.EnvironmentId);
Enabled = options.Enabled;
_protocolVersions = new HashSet<uint>(options.ProtocolVersions);
_regions = options.Regions.Select(static region => new RegionId(region)).ToHashSet();
_visibilityModes = new HashSet<ListingVisibility>(options.VisibilityModes);
_publisherTrustModes = new HashSet<PublisherTrustMode>(options.PublisherTrustModes);
_metadataValueMaxBytes = new Dictionary<string, int>(
options.MetadataValueMaxBytes,
StringComparer.Ordinal);
_requiredMetadataKeys = new HashSet<string>(
options.RequiredMetadataKeys,
StringComparer.Ordinal);
MetadataMaxBytes = options.MetadataMaxBytes;
MetadataMaxKeys = options.MetadataMaxKeys;
MaxListingsPerPrincipal = options.MaxListingsPerPrincipal;
MaxAnonymousListingsPerAddress = options.MaxAnonymousListingsPerAddress;
MaxActiveJoinAttempts = options.MaxActiveJoinAttempts;
FallbackPolicy = options.FallbackPolicy;
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public bool Enabled { get; }
public int MetadataMaxBytes { get; }
public int MetadataMaxKeys { get; }
public int MaxListingsPerPrincipal { get; }
public int MaxAnonymousListingsPerAddress { get; }
public int MaxActiveJoinAttempts { get; }
public FallbackPolicyMode FallbackPolicy { get; }
public bool AllowsProtocol(uint protocolVersion) =>
_protocolVersions.Contains(protocolVersion);
public bool AllowsRegion(RegionId regionId) => _regions.Contains(regionId);
public bool AllowsVisibility(ListingVisibility visibility) =>
_visibilityModes.Contains(visibility);
public bool AllowsPublisherTrust(PublisherTrustMode trustMode) =>
_publisherTrustModes.Contains(trustMode);
public bool AllowsMetadata(IReadOnlyDictionary<string, string>? metadata)
{
if (!ContractValidation.IsMetadataValid(metadata)
|| metadata!.Count > MetadataMaxKeys
|| !_requiredMetadataKeys.IsSubsetOf(metadata.Keys))
{
return false;
}
foreach (KeyValuePair<string, string> item in metadata)
{
if (!_metadataValueMaxBytes.TryGetValue(item.Key, out int maximumBytes)
|| !ContractValidation.IsUtf8LengthWithin(item.Value, maximumBytes))
{
return false;
}
}
return JsonSerializer.SerializeToUtf8Bytes(metadata, ContractJson.Options).Length
<= MetadataMaxBytes;
}
}
@@ -0,0 +1,118 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class GamePolicyRegistry
{
private readonly Dictionary<(GameId Game, EnvironmentId Environment), GamePolicy> _policies;
private GamePolicyRegistry(
Dictionary<(GameId Game, EnvironmentId Environment), GamePolicy> policies) =>
_policies = policies;
public bool HasEnabledPolicies => _policies.Values.Any(static policy => policy.Enabled);
public IEnumerable<GamePolicy> EnabledPolicies =>
_policies.Values.Where(static policy => policy.Enabled);
public static GamePolicyRegistry Create(IEnumerable<GamePolicyOptions> options)
{
GamePolicyOptions[] configuredPolicies = options.ToArray();
if (configuredPolicies.Length > ProvisioningLimits.MaxGamePolicies)
{
throw new ProvisioningConfigurationException(
$"At most {ProvisioningLimits.MaxGamePolicies} game policies may be configured.");
}
Dictionary<(GameId Game, EnvironmentId Environment), GamePolicy> policies = [];
foreach (GamePolicyOptions policyOptions in configuredPolicies)
{
Validate(policyOptions);
GamePolicy policy = new(policyOptions);
if (!policies.TryAdd((policy.GameId, policy.EnvironmentId), policy))
{
throw new ProvisioningConfigurationException(
$"Duplicate game/environment policy: {policy.GameId}/{policy.EnvironmentId}.");
}
}
return new GamePolicyRegistry(policies);
}
public bool TryGet(
GameId gameId,
EnvironmentId environmentId,
out GamePolicy? policy)
{
if (_policies.TryGetValue((gameId, environmentId), out GamePolicy? candidate)
&& candidate.Enabled)
{
policy = candidate;
return true;
}
policy = null;
return false;
}
private static void Validate(GamePolicyOptions options)
{
if (!GameId.TryParse(options.GameId, out _)
|| !EnvironmentId.TryParse(options.EnvironmentId, out _))
{
throw new ProvisioningConfigurationException(
"Game policies require valid game and environment IDs.");
}
if (options.ProtocolVersions.Count is 0 or > ProvisioningLimits.MaxProtocolVersionsPerPolicy
|| options.ProtocolVersions.Contains(0)
|| options.ProtocolVersions.Count != options.ProtocolVersions.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} requires unique non-zero protocol versions.");
}
if (options.Regions.Count is 0 or > ProvisioningLimits.MaxRegionsPerPolicy
|| options.Regions.Any(static region => !RegionId.TryParse(region, out _))
|| options.Regions.Count != options.Regions.Distinct(StringComparer.Ordinal).Count())
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} requires unique valid regions.");
}
if (options.VisibilityModes.Count == 0
|| options.VisibilityModes.Any(static mode => !Enum.IsDefined(mode))
|| options.VisibilityModes.Count != options.VisibilityModes.Distinct().Count()
|| options.PublisherTrustModes.Count == 0
|| options.PublisherTrustModes.Any(static mode => !Enum.IsDefined(mode))
|| options.PublisherTrustModes.Count != options.PublisherTrustModes.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} requires valid visibility and trust modes.");
}
if (options.MetadataMaxBytes is < 2 or > ContractLimits.MetadataMaxBytes
|| options.MetadataMaxKeys is < 0 or > ContractLimits.MetadataMaxKeys
|| options.MetadataValueMaxBytes.Count > options.MetadataMaxKeys
|| options.MetadataValueMaxBytes.Any(static item =>
string.IsNullOrWhiteSpace(item.Key)
|| !ContractValidation.IsUtf8LengthWithin(item.Key, ContractLimits.MetadataKeyMaxBytes)
|| item.Value is < 0 or > ContractLimits.MetadataValueMaxBytes)
|| options.RequiredMetadataKeys.Any(key =>
!options.MetadataValueMaxBytes.ContainsKey(key)))
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} has an invalid metadata schema.");
}
if (options.MaxListingsPerPrincipal is < 1 or > ProvisioningLimits.MaxListingsPerPrincipal
|| options.MaxAnonymousListingsPerAddress < 0
|| options.MaxAnonymousListingsPerAddress > options.MaxListingsPerPrincipal
|| options.MaxActiveJoinAttempts is < 1
or > ProvisioningLimits.MaxActiveJoinAttemptsPerPolicy
|| !Enum.IsDefined(options.FallbackPolicy))
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} has invalid quotas or fallback policy.");
}
}
}
@@ -0,0 +1,451 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class PrincipalCredentialService
{
private const string TokenPrefix = "rv1";
private readonly string _issuer;
private readonly string _audience;
private readonly TimeSpan _clockSkew;
private readonly SigningKeyRing _keyRing;
public PrincipalCredentialService(
string issuer,
string audience,
TimeSpan clockSkew,
SigningKeyRing keyRing)
{
if (!IsSafeAuthority(issuer) || !IsSafeAuthority(audience))
{
throw new ProvisioningConfigurationException(
"Credential issuer and audience are required.");
}
if (clockSkew < TimeSpan.Zero || clockSkew > TimeSpan.FromSeconds(30))
{
throw new ProvisioningConfigurationException(
"Credential clock skew must be between zero and 30 seconds.");
}
_issuer = issuer;
_audience = audience;
_clockSkew = clockSkew;
_keyRing = keyRing;
}
public string Issue(AuthenticatedPrincipal principal, DateTimeOffset now)
{
if (!IsSafeSubject(principal.Subject))
{
throw new ArgumentException(
"Principal subjects must be 1128 visible ASCII characters.",
nameof(principal));
}
if (principal.ExpiresAt <= now)
{
throw new ArgumentException("Cannot issue an already-expired principal.", nameof(principal));
}
CredentialPayload payload = CreatePayload(principal, now);
if (CreatePrincipal(payload, principal.ExpiresAt) is null)
{
throw new ArgumentException(
"The principal contains an invalid kind or scope.",
nameof(principal));
}
if (!_keyRing.TryGetSigningKey(
now,
payload.Kind,
payload.GameId,
payload.EnvironmentId,
out SigningKey? signingKey)
|| signingKey is null)
{
throw new InvalidOperationException("No active signing key is available.");
}
if (principal.ExpiresAt > signingKey.VerifyUntil)
{
throw new InvalidOperationException(
"The active key verification window is shorter than the credential lifetime.");
}
string encodedPayload = Base64Url.Encode(
JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string signedContent = $"{TokenPrefix}.{signingKey.KeyId}.{encodedPayload}";
string signature = Base64Url.Encode(signingKey.Sign(signedContent));
string token = $"{signedContent}.{signature}";
if (!ContractValidation.IsOpaqueHttpCredentialValid(token))
{
throw new InvalidOperationException("The signed credential exceeds the v1 size limit.");
}
return token;
}
public CredentialValidationResult Validate(string? token, DateTimeOffset now)
{
if (!ContractValidation.IsOpaqueHttpCredentialValid(token))
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
string[] segments = token!.Split('.');
if (segments.Length != 4
|| !string.Equals(segments[0], TokenPrefix, StringComparison.Ordinal)
|| segments[1].Length == 0)
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
VerificationKeyLookup lookup = _keyRing.FindVerificationKey(
segments[1],
now,
out SigningKey? signingKey);
if (lookup != VerificationKeyLookup.Available || signingKey is null)
{
return CredentialValidationResult.Invalid(lookup switch
{
VerificationKeyLookup.Revoked => CredentialValidationError.KeyRevoked,
VerificationKeyLookup.NotYetValid => CredentialValidationError.KeyNotYetValid,
VerificationKeyLookup.Retired => CredentialValidationError.KeyRetired,
_ => CredentialValidationError.UnknownKey,
});
}
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
byte[] expectedSignature = signingKey.Sign(signedContent);
bool signatureMatches = suppliedSignature.Length == expectedSignature.Length
&& CryptographicOperations.FixedTimeEquals(suppliedSignature, expectedSignature);
CryptographicOperations.ZeroMemory(suppliedSignature);
CryptographicOperations.ZeroMemory(expectedSignature);
if (!signatureMatches)
{
return CredentialValidationResult.Invalid(CredentialValidationError.SignatureInvalid);
}
if (!Base64Url.TryDecode(segments[2], out byte[]? encodedPayload))
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
CredentialPayload? payload;
try
{
payload = JsonSerializer.Deserialize<CredentialPayload>(
encodedPayload,
ContractJson.Options);
}
catch (JsonException)
{
payload = null;
}
finally
{
CryptographicOperations.ZeroMemory(encodedPayload);
}
if (payload is null || payload.Version != ContractLimits.ContractVersion)
{
return CredentialValidationResult.Invalid(CredentialValidationError.PayloadInvalid);
}
if (!string.Equals(payload.Issuer, _issuer, StringComparison.Ordinal))
{
return CredentialValidationResult.Invalid(CredentialValidationError.IssuerMismatch);
}
if (!string.Equals(payload.Audience, _audience, StringComparison.Ordinal))
{
return CredentialValidationResult.Invalid(CredentialValidationError.AudienceMismatch);
}
if (!signingKey.Authorizes(payload.Kind, payload.GameId, payload.EnvironmentId))
{
return CredentialValidationResult.Invalid(CredentialValidationError.KeyScopeMismatch);
}
DateTimeOffset issuedAt;
DateTimeOffset notBefore;
DateTimeOffset expiresAt;
try
{
issuedAt = DateTimeOffset.FromUnixTimeSeconds(payload.IssuedAtUnixSeconds);
notBefore = DateTimeOffset.FromUnixTimeSeconds(payload.NotBeforeUnixSeconds);
expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAtUnixSeconds);
}
catch (ArgumentOutOfRangeException)
{
return CredentialValidationResult.Invalid(CredentialValidationError.PayloadInvalid);
}
if (issuedAt > now + _clockSkew || notBefore > now + _clockSkew)
{
return CredentialValidationResult.Invalid(CredentialValidationError.NotYetValid);
}
if (expiresAt <= now - _clockSkew || expiresAt <= notBefore)
{
return CredentialValidationResult.Invalid(CredentialValidationError.Expired);
}
if (issuedAt > notBefore
|| issuedAt < signingKey.NotBefore - _clockSkew
|| expiresAt > signingKey.VerifyUntil)
{
return CredentialValidationResult.Invalid(CredentialValidationError.PayloadInvalid);
}
AuthenticatedPrincipal? principal = CreatePrincipal(payload, expiresAt);
return principal is null
? CredentialValidationResult.Invalid(CredentialValidationError.ScopeInvalid)
: CredentialValidationResult.Valid(principal);
}
public override string ToString() => "[PrincipalCredentialService: key material and credentials redacted]";
private CredentialPayload CreatePayload(AuthenticatedPrincipal principal, DateTimeOffset now)
{
CredentialPayload payload = new()
{
Version = ContractLimits.ContractVersion,
Issuer = _issuer,
Audience = _audience,
Subject = principal.Subject,
IssuedAtUnixSeconds = now.ToUnixTimeSeconds(),
NotBeforeUnixSeconds = now.ToUnixTimeSeconds(),
ExpiresAtUnixSeconds = principal.ExpiresAt.ToUnixTimeSeconds(),
Nonce = Guid.NewGuid().ToString("N"),
};
switch (principal)
{
case DedicatedPublisherPrincipal publisher:
SetPublisherPayload(payload, publisher, PrincipalCredentialKind.DedicatedPublisher);
break;
case PlayerHostGrantPrincipal publisher:
SetPublisherPayload(payload, publisher, PrincipalCredentialKind.PlayerHostGrant);
break;
case OperatorPrincipal operatorPrincipal:
payload.Kind = PrincipalCredentialKind.Operator;
payload.Permissions = operatorPrincipal.Permissions.Order().ToList();
break;
default:
throw new ArgumentException(
"Anonymous principals cannot receive reusable signed credentials.",
nameof(principal));
}
return payload;
}
private static void SetPublisherPayload(
CredentialPayload payload,
IPublisherPrincipal publisher,
PrincipalCredentialKind kind)
{
payload.Kind = kind;
payload.GameId = publisher.GameId.ToString();
payload.EnvironmentId = publisher.EnvironmentId.ToString();
payload.Regions = publisher.AllowedRegions
.Select(static region => region.ToString())
.Order(StringComparer.Ordinal)
.ToList();
}
private static AuthenticatedPrincipal? CreatePrincipal(
CredentialPayload payload,
DateTimeOffset expiresAt)
{
if (!IsSafeSubject(payload.Subject)
|| !Guid.TryParseExact(payload.Nonce, "N", out Guid nonce)
|| nonce == Guid.Empty)
{
return null;
}
if (payload.Kind == PrincipalCredentialKind.Operator)
{
if (payload.GameId is not null
|| payload.EnvironmentId is not null
|| payload.Regions.Count != 0
|| payload.Permissions.Count == 0
|| payload.Permissions.Any(static permission => !Enum.IsDefined(permission))
|| payload.Permissions.Count != payload.Permissions.Distinct().Count())
{
return null;
}
return new OperatorPrincipal(
payload.Subject,
expiresAt,
new HashSet<OperatorPermission>(payload.Permissions));
}
if (!GameId.TryParse(payload.GameId, out GameId gameId)
|| !EnvironmentId.TryParse(payload.EnvironmentId, out EnvironmentId environmentId)
|| payload.Regions.Count == 0
|| payload.Regions.Any(static region => !RegionId.TryParse(region, out _))
|| payload.Regions.Count != payload.Regions.Distinct(StringComparer.Ordinal).Count()
|| payload.Permissions.Count != 0)
{
return null;
}
HashSet<RegionId> regions = payload.Regions.Select(static region => new RegionId(region)).ToHashSet();
return payload.Kind switch
{
PrincipalCredentialKind.DedicatedPublisher => new DedicatedPublisherPrincipal(
payload.Subject,
expiresAt,
gameId,
environmentId,
regions),
PrincipalCredentialKind.PlayerHostGrant => new PlayerHostGrantPrincipal(
payload.Subject,
expiresAt,
gameId,
environmentId,
regions),
_ => null,
};
}
private static bool IsSafeSubject(string? value) =>
value is not null
&& value.Length is > 0 and <= 128
&& value.All(static character => character is >= '!' and <= '~');
private static bool IsSafeAuthority(string? value) =>
value is not null
&& value.Length is > 0 and <= 128
&& value.All(static character => character is >= '!' and <= '~');
}
internal sealed class CredentialPayload
{
[JsonRequired]
public int Version { get; set; }
[JsonRequired]
public string Issuer { get; set; } = string.Empty;
[JsonRequired]
public string Audience { get; set; } = string.Empty;
[JsonRequired]
public string Subject { get; set; } = string.Empty;
[JsonRequired]
public PrincipalCredentialKind Kind { get; set; }
public string? GameId { get; set; }
public string? EnvironmentId { get; set; }
public List<string> Regions { get; set; } = [];
public List<OperatorPermission> Permissions { get; set; } = [];
[JsonRequired]
public long IssuedAtUnixSeconds { get; set; }
[JsonRequired]
public long NotBeforeUnixSeconds { get; set; }
[JsonRequired]
public long ExpiresAtUnixSeconds { get; set; }
[JsonRequired]
public string Nonce { get; set; } = string.Empty;
}
internal readonly record struct CredentialValidationResult(
bool IsValid,
CredentialValidationError Error,
AuthenticatedPrincipal? Principal)
{
public static CredentialValidationResult Valid(AuthenticatedPrincipal principal) =>
new(true, CredentialValidationError.None, principal);
public static CredentialValidationResult Invalid(CredentialValidationError error) =>
new(false, error, null);
public override string ToString() => $"[CredentialValidation: {Error}, credential redacted]";
}
internal enum CredentialValidationError
{
None = 0,
Malformed = 1,
UnknownKey = 2,
KeyRevoked = 3,
KeyNotYetValid = 4,
KeyRetired = 5,
SignatureInvalid = 6,
PayloadInvalid = 7,
IssuerMismatch = 8,
AudienceMismatch = 9,
KeyScopeMismatch = 10,
NotYetValid = 11,
Expired = 12,
ScopeInvalid = 13,
}
internal static class Base64Url
{
public static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
public static bool TryDecode(string value, out byte[] bytes)
{
bytes = [];
if (string.IsNullOrEmpty(value)
|| value.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
return false;
}
string padded = value.Replace('-', '+').Replace('_', '/');
int remainder = padded.Length % 4;
if (remainder == 1)
{
return false;
}
padded += remainder switch
{
0 => string.Empty,
2 => "==",
3 => "=",
_ => string.Empty,
};
try
{
bytes = Convert.FromBase64String(padded);
return true;
}
catch (FormatException)
{
return false;
}
}
}
@@ -0,0 +1,107 @@
using System.Collections.Frozen;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal abstract record AuthenticatedPrincipal(
string Subject,
DateTimeOffset ExpiresAt);
internal interface IPublisherPrincipal
{
string Subject { get; }
DateTimeOffset ExpiresAt { get; }
GameId GameId { get; }
EnvironmentId EnvironmentId { get; }
PublisherTrustMode TrustMode { get; }
IReadOnlySet<RegionId> AllowedRegions { get; }
}
internal sealed record DedicatedPublisherPrincipal : AuthenticatedPrincipal, IPublisherPrincipal
{
public DedicatedPublisherPrincipal(
string subject,
DateTimeOffset expiresAt,
GameId gameId,
EnvironmentId environmentId,
IEnumerable<RegionId> allowedRegions)
: base(subject, expiresAt)
{
GameId = gameId;
EnvironmentId = environmentId;
AllowedRegions = allowedRegions.ToFrozenSet();
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public IReadOnlySet<RegionId> AllowedRegions { get; }
public PublisherTrustMode TrustMode => PublisherTrustMode.ManagedDedicated;
}
internal sealed record PlayerHostGrantPrincipal : AuthenticatedPrincipal, IPublisherPrincipal
{
public PlayerHostGrantPrincipal(
string subject,
DateTimeOffset expiresAt,
GameId gameId,
EnvironmentId environmentId,
IEnumerable<RegionId> allowedRegions)
: base(subject, expiresAt)
{
GameId = gameId;
EnvironmentId = environmentId;
AllowedRegions = allowedRegions.ToFrozenSet();
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public IReadOnlySet<RegionId> AllowedRegions { get; }
public PublisherTrustMode TrustMode => PublisherTrustMode.PlayerGrant;
}
internal sealed record AnonymousUnlistedPrincipal : AuthenticatedPrincipal, IPublisherPrincipal
{
public AnonymousUnlistedPrincipal(
string subject,
DateTimeOffset expiresAt,
GameId gameId,
EnvironmentId environmentId,
IEnumerable<RegionId> allowedRegions)
: base(subject, expiresAt)
{
GameId = gameId;
EnvironmentId = environmentId;
AllowedRegions = allowedRegions.ToFrozenSet();
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public IReadOnlySet<RegionId> AllowedRegions { get; }
public PublisherTrustMode TrustMode => PublisherTrustMode.AnonymousUnlisted;
}
internal sealed record OperatorPrincipal : AuthenticatedPrincipal
{
public OperatorPrincipal(
string subject,
DateTimeOffset expiresAt,
IEnumerable<OperatorPermission> permissions)
: base(subject, expiresAt) => Permissions = permissions.ToFrozenSet();
public IReadOnlySet<OperatorPermission> Permissions { get; }
}
internal enum OperatorPermission
{
ReadPolicy = 1,
ManagePolicy = 2,
RevokePublisher = 3,
RotateKeys = 4,
}
internal enum PrincipalCredentialKind
{
DedicatedPublisher = 1,
PlayerHostGrant = 2,
Operator = 3,
}
@@ -0,0 +1,71 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class ProvisioningOptions
{
public const string SectionName = "Rendezvous:Provisioning";
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public int ClockSkewSeconds { get; set; } = 30;
public List<SigningKeyOptions> SigningKeys { get; set; } = [];
public List<GamePolicyOptions> Games { get; set; } = [];
}
internal sealed class SigningKeyOptions
{
public string KeyId { get; set; } = string.Empty;
public string SecretReference { get; set; } = string.Empty;
public List<PrincipalCredentialKind> CredentialKinds { get; set; } = [];
public string? GameId { get; set; }
public string? EnvironmentId { get; set; }
public DateTimeOffset NotBefore { get; set; }
public DateTimeOffset SignUntil { get; set; }
public DateTimeOffset VerifyUntil { get; set; }
public bool Revoked { get; set; }
}
internal sealed class GamePolicyOptions
{
public string GameId { get; set; } = string.Empty;
public string EnvironmentId { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public List<uint> ProtocolVersions { get; set; } = [];
public List<string> Regions { get; set; } = [];
public List<ListingVisibility> VisibilityModes { get; set; } = [];
public List<PublisherTrustMode> PublisherTrustModes { get; set; } = [];
public Dictionary<string, int> MetadataValueMaxBytes { get; set; } =
new(StringComparer.Ordinal);
public List<string> RequiredMetadataKeys { get; set; } = [];
public int MetadataMaxBytes { get; set; } = ContractLimits.MetadataMaxBytes;
public int MetadataMaxKeys { get; set; } = ContractLimits.MetadataMaxKeys;
public int MaxListingsPerPrincipal { get; set; } = 100;
public int MaxAnonymousListingsPerAddress { get; set; } = 2;
public int MaxActiveJoinAttempts { get; set; } = 1_000;
public FallbackPolicyMode FallbackPolicy { get; set; }
}
internal enum FallbackPolicyMode
{
Disabled = 0,
DedicatedEndpointAllowed = 1,
}
internal static class ProvisioningLimits
{
public const int MaxGamePolicies = 1_024;
public const int MaxSigningKeys = 128;
public const int MaxProtocolVersionsPerPolicy = 64;
public const int MaxRegionsPerPolicy = 32;
public const int MaxListingsPerPrincipal = 25_000;
public const int MaxActiveJoinAttemptsPerPolicy = 10_000;
}
internal sealed class ProvisioningConfigurationException : Exception
{
public ProvisioningConfigurationException(string message)
: base(message)
{
}
}
@@ -0,0 +1,120 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class ProvisioningRuntime : IDisposable
{
private readonly IDisposable? _secretProviderLifetime;
private ProvisioningRuntime(
GamePolicyRegistry policies,
SigningKeyRing signingKeys,
PrincipalCredentialService credentials,
PublisherAuthorizationService publisherAuthorization,
IDisposable? secretProviderLifetime)
{
Policies = policies;
SigningKeys = signingKeys;
Credentials = credentials;
PublisherAuthorization = publisherAuthorization;
_secretProviderLifetime = secretProviderLifetime;
}
public GamePolicyRegistry Policies { get; }
public SigningKeyRing SigningKeys { get; }
public PrincipalCredentialService Credentials { get; }
public PublisherAuthorizationService PublisherAuthorization { get; }
public static ProvisioningRuntime Create(
ProvisioningOptions options,
ISecretProvider secretProvider,
DateTimeOffset now)
{
try
{
SigningKeyRing signingKeys = SigningKeyRing.Create(options.SigningKeys, secretProvider);
try
{
if (!signingKeys.HasKeys
|| !signingKeys.HasActiveSigningKey(now))
{
throw new ProvisioningConfigurationException(
"At least one active signing key with available production key material is required.");
}
GamePolicyRegistry policies = GamePolicyRegistry.Create(options.Games);
if (!policies.HasEnabledPolicies)
{
throw new ProvisioningConfigurationException(
"At least one enabled game/environment policy is required.");
}
foreach (GamePolicy policy in policies.EnabledPolicies)
{
RequirePublisherKey(
signingKeys,
policy,
PublisherTrustMode.ManagedDedicated,
PrincipalCredentialKind.DedicatedPublisher,
now);
RequirePublisherKey(
signingKeys,
policy,
PublisherTrustMode.PlayerGrant,
PrincipalCredentialKind.PlayerHostGrant,
now);
}
PrincipalCredentialService credentials = new(
options.Issuer,
options.Audience,
TimeSpan.FromSeconds(options.ClockSkewSeconds),
signingKeys);
PublisherAuthorizationService authorization = new(policies);
return new ProvisioningRuntime(
policies,
signingKeys,
credentials,
authorization,
secretProvider as IDisposable);
}
catch
{
signingKeys.Dispose();
throw;
}
}
catch
{
(secretProvider as IDisposable)?.Dispose();
throw;
}
}
public void Dispose()
{
SigningKeys.Dispose();
_secretProviderLifetime?.Dispose();
}
private static void RequirePublisherKey(
SigningKeyRing signingKeys,
GamePolicy policy,
PublisherTrustMode trustMode,
PrincipalCredentialKind credentialKind,
DateTimeOffset now)
{
if (policy.AllowsPublisherTrust(trustMode)
&& !signingKeys.HasActiveSigningKey(
now,
credentialKind,
policy.GameId.ToString(),
policy.EnvironmentId.ToString()))
{
throw new ProvisioningConfigurationException(
$"Policy {policy.GameId}/{policy.EnvironmentId} has no active {credentialKind} key.");
}
}
}
internal sealed record ProvisioningReadiness(bool IsReady);
@@ -0,0 +1,119 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class PublisherAuthorizationService(GamePolicyRegistry policies)
{
public PublisherAuthorizationResult Authorize(
AuthenticatedPrincipal principal,
GameId requestedGameId,
EnvironmentId requestedEnvironmentId,
RegionId requestedRegionId,
uint requestedProtocolVersion,
ListingVisibility requestedVisibility,
IReadOnlyDictionary<string, string> requestedMetadata,
DateTimeOffset now)
{
if (principal.ExpiresAt <= now)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.PrincipalExpired);
}
if (principal is not IPublisherPrincipal publisher)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.NotPublisher);
}
if (publisher.GameId != requestedGameId
|| publisher.EnvironmentId != requestedEnvironmentId)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.ScopeMismatch);
}
if (!policies.TryGet(publisher.GameId, publisher.EnvironmentId, out GamePolicy? policy)
|| policy is null)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.PolicyNotFound);
}
if (!policy.AllowsPublisherTrust(publisher.TrustMode))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.TrustModeNotAllowed);
}
if (!publisher.AllowedRegions.Contains(requestedRegionId)
|| !policy.AllowsRegion(requestedRegionId))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.RegionNotAllowed);
}
if (!policy.AllowsProtocol(requestedProtocolVersion))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.ProtocolNotAllowed);
}
if (publisher.TrustMode == PublisherTrustMode.AnonymousUnlisted
&& requestedVisibility != ListingVisibility.Unlisted)
{
return PublisherAuthorizationResult.Denied(
PublisherAuthorizationError.AnonymousMustBeUnlisted);
}
if (!policy.AllowsVisibility(requestedVisibility))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.VisibilityNotAllowed);
}
if (!policy.AllowsMetadata(requestedMetadata))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.MetadataNotAllowed);
}
return PublisherAuthorizationResult.Allowed(new AuthorizedPublisherContext(
publisher.Subject,
publisher.GameId,
publisher.EnvironmentId,
requestedRegionId,
requestedProtocolVersion,
requestedVisibility,
publisher.TrustMode,
policy));
}
}
internal sealed record AuthorizedPublisherContext(
string Subject,
GameId GameId,
EnvironmentId EnvironmentId,
RegionId RegionId,
uint ProtocolVersion,
ListingVisibility Visibility,
PublisherTrustMode TrustMode,
GamePolicy Policy);
internal readonly record struct PublisherAuthorizationResult(
bool IsAllowed,
PublisherAuthorizationError Error,
AuthorizedPublisherContext? Context)
{
public static PublisherAuthorizationResult Allowed(AuthorizedPublisherContext context) =>
new(true, PublisherAuthorizationError.None, context);
public static PublisherAuthorizationResult Denied(PublisherAuthorizationError error) =>
new(false, error, null);
}
internal enum PublisherAuthorizationError
{
None = 0,
NotPublisher = 1,
ScopeMismatch = 2,
PolicyNotFound = 3,
TrustModeNotAllowed = 4,
RegionNotAllowed = 5,
ProtocolNotAllowed = 6,
AnonymousMustBeUnlisted = 7,
VisibilityNotAllowed = 8,
MetadataNotAllowed = 9,
PrincipalExpired = 10,
}
@@ -0,0 +1,144 @@
using System.Security.Cryptography;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal interface ISecretProvider
{
bool TryGetSecret(string reference, out SecretMaterial? secret);
}
internal sealed class SecretMaterial : IDisposable
{
private byte[]? _bytes;
public SecretMaterial(ReadOnlySpan<byte> bytes)
{
if (bytes.Length == 0)
{
throw new ArgumentException("Secret material cannot be empty.", nameof(bytes));
}
_bytes = bytes.ToArray();
}
public int Length => _bytes?.Length ?? 0;
public byte[] CopyBytes() => _bytes?.ToArray()
?? throw new ObjectDisposedException(nameof(SecretMaterial));
public void Dispose()
{
if (_bytes is not null)
{
CryptographicOperations.ZeroMemory(_bytes);
_bytes = null;
}
}
public override string ToString() => "[REDACTED SECRET]";
}
internal sealed class EnvironmentSecretProvider : ISecretProvider
{
private const string Prefix = "env:";
public bool TryGetSecret(string reference, out SecretMaterial? secret)
{
secret = null;
if (!reference.StartsWith(Prefix, StringComparison.Ordinal)
|| reference.Length == Prefix.Length)
{
return false;
}
string? encoded = Environment.GetEnvironmentVariable(reference[Prefix.Length..]);
if (string.IsNullOrEmpty(encoded))
{
return false;
}
try
{
byte[] bytes = Convert.FromBase64String(encoded);
secret = new SecretMaterial(bytes);
CryptographicOperations.ZeroMemory(bytes);
return true;
}
catch (FormatException)
{
return false;
}
}
}
internal sealed class EphemeralDevelopmentSecretProvider : ISecretProvider, IDisposable
{
private const string Prefix = "development:ephemeral/";
private readonly Dictionary<string, byte[]> _secrets = new(StringComparer.Ordinal);
public bool TryGetSecret(string reference, out SecretMaterial? secret)
{
secret = null;
if (!reference.StartsWith(Prefix, StringComparison.Ordinal)
|| reference.Length == Prefix.Length)
{
return false;
}
if (!_secrets.TryGetValue(reference, out byte[]? bytes))
{
bytes = RandomNumberGenerator.GetBytes(32);
_secrets.Add(reference, bytes);
}
secret = new SecretMaterial(bytes);
return true;
}
public void Dispose()
{
foreach (byte[] bytes in _secrets.Values)
{
CryptographicOperations.ZeroMemory(bytes);
}
_secrets.Clear();
}
public override string ToString() => "[EphemeralDevelopmentSecretProvider]";
}
internal sealed class DictionarySecretProvider : ISecretProvider, IDisposable
{
private readonly Dictionary<string, byte[]> _secrets;
public DictionarySecretProvider(IReadOnlyDictionary<string, byte[]> secrets) =>
_secrets = secrets.ToDictionary(
static item => item.Key,
static item => item.Value.ToArray(),
StringComparer.Ordinal);
public bool TryGetSecret(string reference, out SecretMaterial? secret)
{
if (_secrets.TryGetValue(reference, out byte[]? bytes))
{
secret = new SecretMaterial(bytes);
return true;
}
secret = null;
return false;
}
public void Dispose()
{
foreach (byte[] bytes in _secrets.Values)
{
CryptographicOperations.ZeroMemory(bytes);
}
_secrets.Clear();
}
public override string ToString() => "[DictionarySecretProvider: REDACTED]";
}
@@ -0,0 +1,275 @@
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class SigningKeyRing : IDisposable
{
private readonly Dictionary<string, SigningKey> _keys;
private readonly ConcurrentDictionary<string, byte> _runtimeRevocations =
new(StringComparer.Ordinal);
private SigningKeyRing(Dictionary<string, SigningKey> keys) => _keys = keys;
public bool HasKeys => _keys.Count > 0;
public static SigningKeyRing Create(
IEnumerable<SigningKeyOptions> options,
ISecretProvider secretProvider)
{
SigningKeyOptions[] configuredKeys = options.ToArray();
if (configuredKeys.Length > ProvisioningLimits.MaxSigningKeys)
{
throw new ProvisioningConfigurationException(
$"At most {ProvisioningLimits.MaxSigningKeys} signing keys may be configured.");
}
Dictionary<string, SigningKey> keys = new(StringComparer.Ordinal);
try
{
foreach (SigningKeyOptions keyOptions in configuredKeys)
{
Validate(keyOptions);
if (keys.ContainsKey(keyOptions.KeyId))
{
throw new ProvisioningConfigurationException(
$"Duplicate signing key ID '{keyOptions.KeyId}'.");
}
if (keyOptions.Revoked)
{
keys.Add(keyOptions.KeyId, new SigningKey(keyOptions, null));
continue;
}
if (!secretProvider.TryGetSecret(
keyOptions.SecretReference,
out SecretMaterial? material)
|| material is null)
{
throw new ProvisioningConfigurationException(
$"Signing key '{keyOptions.KeyId}' has no available key material.");
}
using (material)
{
if (material.Length < 32)
{
throw new ProvisioningConfigurationException(
$"Signing key '{keyOptions.KeyId}' must contain at least 32 bytes.");
}
keys.Add(keyOptions.KeyId, new SigningKey(keyOptions, material.CopyBytes()));
}
}
return new SigningKeyRing(keys);
}
catch
{
foreach (SigningKey key in keys.Values)
{
key.Dispose();
}
throw;
}
}
public bool HasActiveSigningKey(DateTimeOffset now) => _keys.Values.Any(key =>
!IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil);
public bool HasActiveSigningKey(
DateTimeOffset now,
PrincipalCredentialKind kind,
string? gameId,
string? environmentId) => _keys.Values.Any(key =>
!IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil
&& key.Authorizes(kind, gameId, environmentId));
public bool TryGetSigningKey(
DateTimeOffset now,
PrincipalCredentialKind kind,
string? gameId,
string? environmentId,
out SigningKey? signingKey)
{
signingKey = _keys.Values
.Where(key => !IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil
&& key.Authorizes(kind, gameId, environmentId))
.OrderByDescending(static key => key.NotBefore)
.ThenByDescending(static key => key.KeyId, StringComparer.Ordinal)
.FirstOrDefault();
return signingKey is not null;
}
public VerificationKeyLookup FindVerificationKey(
string keyId,
DateTimeOffset now,
out SigningKey? signingKey)
{
signingKey = null;
if (!_keys.TryGetValue(keyId, out SigningKey? candidate))
{
return VerificationKeyLookup.Unknown;
}
if (IsRevoked(candidate))
{
return VerificationKeyLookup.Revoked;
}
if (now < candidate.NotBefore)
{
return VerificationKeyLookup.NotYetValid;
}
if (now >= candidate.VerifyUntil)
{
return VerificationKeyLookup.Retired;
}
signingKey = candidate;
return VerificationKeyLookup.Available;
}
public bool Revoke(string keyId) =>
_keys.ContainsKey(keyId) && _runtimeRevocations.TryAdd(keyId, 0);
public void Dispose()
{
foreach (SigningKey key in _keys.Values)
{
key.Dispose();
}
_keys.Clear();
_runtimeRevocations.Clear();
}
public override string ToString() => $"[SigningKeyRing: {_keys.Count} keys, material redacted]";
private bool IsRevoked(SigningKey key) =>
key.ConfiguredRevoked || _runtimeRevocations.ContainsKey(key.KeyId);
private static void Validate(SigningKeyOptions options)
{
if (string.IsNullOrEmpty(options.KeyId)
|| options.KeyId.Length > 64
|| options.KeyId.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
throw new ProvisioningConfigurationException(
"Signing key IDs must be 164 base64url characters.");
}
if (string.IsNullOrWhiteSpace(options.SecretReference)
|| options.NotBefore >= options.SignUntil
|| options.SignUntil > options.VerifyUntil)
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' has an invalid secret reference or lifetime.");
}
if (options.CredentialKinds.Count == 0
|| options.CredentialKinds.Any(static kind => !Enum.IsDefined(kind))
|| options.CredentialKinds.Count != options.CredentialKinds.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' requires unique valid credential kinds.");
}
bool operatorKey = options.CredentialKinds.Contains(PrincipalCredentialKind.Operator);
bool hasPublisherKind = options.CredentialKinds.Any(static kind =>
kind is PrincipalCredentialKind.DedicatedPublisher
or PrincipalCredentialKind.PlayerHostGrant);
if (operatorKey
? options.CredentialKinds.Count != 1
|| options.GameId is not null
|| options.EnvironmentId is not null
: !hasPublisherKind
|| !GameId.TryParse(options.GameId, out _)
|| !EnvironmentId.TryParse(options.EnvironmentId, out _))
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' must be operator-only or bound to one game/environment.");
}
}
}
internal sealed class SigningKey : IDisposable
{
private byte[]? _material;
public SigningKey(SigningKeyOptions options, byte[]? material)
{
KeyId = options.KeyId;
NotBefore = options.NotBefore;
SignUntil = options.SignUntil;
VerifyUntil = options.VerifyUntil;
ConfiguredRevoked = options.Revoked;
CredentialKinds = options.CredentialKinds.ToFrozenSet();
GameId = options.GameId;
EnvironmentId = options.EnvironmentId;
_material = material;
}
public string KeyId { get; }
public DateTimeOffset NotBefore { get; }
public DateTimeOffset SignUntil { get; }
public DateTimeOffset VerifyUntil { get; }
public bool ConfiguredRevoked { get; }
public IReadOnlySet<PrincipalCredentialKind> CredentialKinds { get; }
public string? GameId { get; }
public string? EnvironmentId { get; }
public bool Authorizes(
PrincipalCredentialKind kind,
string? gameId,
string? environmentId) =>
CredentialKinds.Contains(kind)
&& (kind == PrincipalCredentialKind.Operator
? gameId is null && environmentId is null
: string.Equals(GameId, gameId, StringComparison.Ordinal)
&& string.Equals(EnvironmentId, environmentId, StringComparison.Ordinal));
public byte[] Sign(string input)
{
ObjectDisposedException.ThrowIf(_material is null, this);
return HMACSHA256.HashData(_material, Encoding.ASCII.GetBytes(input));
}
public void Dispose()
{
if (_material is not null)
{
CryptographicOperations.ZeroMemory(_material);
_material = null;
}
}
public override string ToString() => $"[SigningKey {KeyId}: material redacted]";
}
internal enum VerificationKeyLookup
{
Available = 0,
Unknown = 1,
Revoked = 2,
NotYetValid = 3,
Retired = 4,
}
@@ -0,0 +1,43 @@
{
"Rendezvous": {
"Provisioning": {
"Issuer": "final-factory-rendezvous-development",
"Audience": "final-factory-rendezvous",
"ClockSkewSeconds": 30,
"SigningKeys": [
{
"KeyId": "development-ephemeral-1",
"SecretReference": "development:ephemeral/rendezvous-signing",
"CredentialKinds": ["DedicatedPublisher", "PlayerHostGrant"],
"GameId": "space-game",
"EnvironmentId": "development",
"NotBefore": "2025-01-01T00:00:00Z",
"SignUntil": "2035-01-01T00:00:00Z",
"VerifyUntil": "2035-01-02T00:00:00Z"
}
],
"Games": [
{
"GameId": "space-game",
"EnvironmentId": "development",
"Enabled": true,
"ProtocolVersions": [1],
"Regions": ["local"],
"VisibilityModes": ["Public", "Unlisted"],
"PublisherTrustModes": ["ManagedDedicated", "PlayerGrant", "AnonymousUnlisted"],
"MetadataValueMaxBytes": {
"map": 64,
"mode": 32
},
"RequiredMetadataKeys": [],
"MetadataMaxBytes": 512,
"MetadataMaxKeys": 2,
"MaxListingsPerPrincipal": 10,
"MaxAnonymousListingsPerAddress": 1,
"MaxActiveJoinAttempts": 100,
"FallbackPolicy": "Disabled"
}
]
}
}
}
@@ -8,8 +8,29 @@
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "1ihb8FO9cGgEK1/m3CTtT/SfnynwmiZib0W2pcDVj3KSWk/Sca4VOXEtaptKQc582zpFrzTFiwkGRCglt6H+WQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.Extensions.ApiDescription.Server": {
"type": "Direct",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "n1m7EAbCbHMGiTy++F+mLSan4MrZe0t00XEpJrkai6BFpB6lwEcirflI9FiMm4G4u55h/2RnWToYBDwS+MnN6g=="
},
"finalfactory.rendezvous.contracts": {
"type": "Project"
},
"Microsoft.OpenApi": {
"type": "CentralTransitive",
"requested": "[2.7.5, )",
"resolved": "2.7.5",
"contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w=="
}
}
}
@@ -8,6 +8,16 @@
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA=="
},
"finalfactory.rendezvous.client": {
"type": "Project",
"dependencies": {
@@ -16,7 +26,20 @@
}
},
"finalfactory.rendezvous.contracts": {
"type": "Project"
"type": "Project",
"dependencies": {
"System.Text.Json": "[10.0.10, )"
}
},
"System.Text.Json": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==",
"dependencies": {
"System.IO.Pipelines": "10.0.10",
"System.Text.Encodings.Web": "10.0.10"
}
}
}
}
@@ -39,7 +39,7 @@ public sealed class DependencyBoundaryTests
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj",
[],
[]);
["System.Text.Json"]);
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj",
["FinalFactory.Rendezvous.Contracts"],
@@ -47,7 +47,11 @@ public sealed class DependencyBoundaryTests
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj",
["FinalFactory.Rendezvous.Contracts"],
["LiteNetLib"]);
[
"LiteNetLib",
"Microsoft.AspNetCore.OpenApi",
"Microsoft.Extensions.ApiDescription.Server",
]);
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj",
["FinalFactory.Rendezvous.Client", "FinalFactory.Rendezvous.Contracts"],
@@ -0,0 +1,140 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class ContractLimitTests
{
[Fact]
public void ByteAndCollectionLimitsAcceptTheBoundaryOnly()
{
Assert.True(ContractValidation.IsHttpRequestSizeValid(ContractLimits.HttpRequestMaxBytes));
Assert.False(ContractValidation.IsHttpRequestSizeValid(ContractLimits.HttpRequestMaxBytes + 1));
Assert.True(ContractValidation.IsBrowserResponseSizeValid(ContractLimits.BrowserResponseMaxBytes));
Assert.False(ContractValidation.IsBrowserResponseSizeValid(ContractLimits.BrowserResponseMaxBytes + 1));
Assert.True(ContractValidation.IsPageSizeValid(ContractLimits.BrowserPageMaxItems));
Assert.False(ContractValidation.IsPageSizeValid(ContractLimits.BrowserPageMaxItems + 1));
Assert.False(ContractValidation.IsPageSizeValid(0));
}
[Fact]
public void Utf8LimitsCountBytesInsteadOfCharacters()
{
string atLimit = new('é', ContractLimits.DisplayNameMaxBytes / 2);
string overLimit = atLimit + "é";
Assert.True(ContractValidation.IsDisplayNameValid(atLimit));
Assert.False(ContractValidation.IsDisplayNameValid(overLimit));
Assert.True(ContractValidation.IsBuildVersionValid(
new string('a', ContractLimits.BuildVersionMaxBytes)));
Assert.False(ContractValidation.IsBuildVersionValid(
new string('a', ContractLimits.BuildVersionMaxBytes + 1)));
}
[Fact]
public void CapabilityLimitStaysBelowLiteNetLibTokenLimit()
{
Assert.True(ContractValidation.IsCapabilityValid(
new string('a', ContractLimits.UdpCapabilityMaxCharacters - 1)));
Assert.True(ContractValidation.IsCapabilityValid(
new string('a', ContractLimits.UdpCapabilityMaxCharacters)));
Assert.False(ContractValidation.IsCapabilityValid(
new string('a', ContractLimits.UdpCapabilityMaxCharacters + 1)));
Assert.False(ContractValidation.IsCapabilityValid("not+base64url"));
Assert.True(
ContractLimits.UdpCapabilityMaxCharacters
< ContractLimits.LiteNetLibNatTokenMaxCharacters);
}
[Fact]
public void CapacityAndMetadataLimitsAreBounded()
{
Assert.True(ContractValidation.IsCapacityValid(new SessionCapacity
{
CurrentPlayers = ContractLimits.SessionCapacityMaxPlayers,
MaximumPlayers = ContractLimits.SessionCapacityMaxPlayers,
}));
Assert.False(ContractValidation.IsCapacityValid(new SessionCapacity
{
CurrentPlayers = 0,
MaximumPlayers = ContractLimits.SessionCapacityMaxPlayers + 1,
}));
Dictionary<string, string> maximumKeys = Enumerable
.Range(0, ContractLimits.MetadataMaxKeys)
.ToDictionary(index => $"key-{index}", _ => "value", StringComparer.Ordinal);
Dictionary<string, string> tooManyKeys = new(maximumKeys, StringComparer.Ordinal)
{
["overflow"] = "value",
};
Assert.True(ContractValidation.IsMetadataValid(maximumKeys));
Assert.False(ContractValidation.IsMetadataValid(tooManyKeys));
Assert.False(ContractValidation.IsMetadataValid(new Dictionary<string, string>
{
[new string('k', ContractLimits.MetadataKeyMaxBytes + 1)] = "value",
}));
Assert.False(ContractValidation.IsMetadataValid(new Dictionary<string, string>
{
["key"] = new string('v', ContractLimits.MetadataValueMaxBytes + 1),
}));
}
[Fact]
public void MetadataDocumentLimitAcceptsExactlyFourKibibytes()
{
Dictionary<string, string> atLimit = Enumerable
.Range(0, ContractLimits.MetadataMaxKeys)
.ToDictionary(index => $"k{index:00}", _ => string.Empty, StringComparer.Ordinal);
for (int index = 0; index < 14; index++)
{
atLimit[$"k{index:00}"] = new string('v', ContractLimits.MetadataValueMaxBytes);
}
atLimit["k14"] = new string('v', 223);
Dictionary<string, string> overLimit = new(atLimit, StringComparer.Ordinal)
{
["k14"] = new string('v', 224),
};
Assert.True(ContractValidation.IsMetadataValid(atLimit));
Assert.False(ContractValidation.IsMetadataValid(overLimit));
}
[Fact]
public void EndpointValidationIsAddressFamilyAware()
{
Assert.True(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "192.0.2.10",
Port = 9050,
}));
Assert.True(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv6,
Address = "2001:db8::10",
Port = 9050,
}));
Assert.False(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "2001:db8::10",
Port = 9050,
}));
}
[Fact]
public void ScopeAndOpaqueTextBoundariesAreEnforced()
{
Assert.True(GameId.TryParse(new string('a', ContractLimits.GameIdMaxCharacters), out _));
Assert.False(GameId.TryParse(
new string('a', ContractLimits.GameIdMaxCharacters + 1),
out _));
Assert.True(ContractValidation.IsIdempotencyKeyValid(
new string('i', ContractLimits.IdempotencyKeyMaxCharacters)));
Assert.False(ContractValidation.IsIdempotencyKeyValid(
new string('i', ContractLimits.IdempotencyKeyMaxCharacters + 1)));
Assert.True(ContractValidation.IsCursorValid(null));
Assert.False(ContractValidation.IsCursorValid("contains whitespace"));
}
}
@@ -0,0 +1,104 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class ContractSerializationTests
{
public static TheoryData<string, Type> GoldenJsonVectors => new()
{
{ "register-session.json", typeof(RegisterSessionRequest) },
{ "browse-sessions.json", typeof(BrowseSessionsResponse) },
{ "create-join-response.json", typeof(CreateJoinAttemptResponse) },
{ "api-error.json", typeof(ApiError) },
};
[Theory]
[MemberData(nameof(GoldenJsonVectors))]
public void CanonicalJsonRoundtripsGoldenVectors(string fileName, Type contractType)
{
string expected = ContractTestFiles.Read(fileName);
object? value = JsonSerializer.Deserialize(expected, contractType, ContractJson.Options);
Assert.NotNull(value);
Assert.Equal(expected, JsonSerializer.Serialize(value, contractType, ContractJson.Options));
}
[Fact]
public void IdentifiersAreSerializedAsStrings()
{
SessionListingId id = new(new Guid("00112233-4455-6677-8899-aabbccddeeff"));
Assert.Equal(
"\"00112233-4455-6677-8899-aabbccddeeff\"",
JsonSerializer.Serialize(id, ContractJson.Options));
Assert.Equal(id, JsonSerializer.Deserialize<SessionListingId>(
"\"00112233-4455-6677-8899-aabbccddeeff\"",
ContractJson.Options));
}
[Fact]
public void UnknownObjectFieldsAreIgnoredForAdditiveV1Changes()
{
const string json = """
{"contractVersion":1,"idempotencyKey":"join-001","gameId":"space-game","environmentId":"production","listingId":"00112233-4455-6677-8899-aabbccddeeff","protocolVersion":7,"futureField":{"nested":true}}
""";
CreateJoinAttemptRequest? request = JsonSerializer.Deserialize<CreateJoinAttemptRequest>(
json,
ContractJson.Options);
Assert.NotNull(request);
Assert.Equal(new GameId("space-game"), request.GameId);
Assert.Equal(7u, request.ProtocolVersion);
}
[Fact]
public void MissingNormativeFieldsAreRejectedInsteadOfDefaulted()
{
const string missingVersion = """
{"idempotencyKey":"join-001","gameId":"space-game","environmentId":"production","listingId":"00112233-4455-6677-8899-aabbccddeeff","protocolVersion":7}
""";
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<CreateJoinAttemptRequest>(
missingVersion,
ContractJson.Options));
}
[Fact]
public void UnknownEnumNamesAndNumericValuesAreRejected()
{
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}",
ContractJson.Options));
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}",
ContractJson.Options));
}
[Theory]
[InlineData(0)]
[InlineData(2)]
[InlineData(int.MaxValue)]
public void UnknownContractVersionsFailPredictably(int version)
{
Assert.Equal(
RendezvousErrorCode.UnsupportedContractVersion,
ContractValidation.ValidateContractVersion(version));
}
[Fact]
public void GameplayProtocolCompatibilityIsExactAndBuildIndependent()
{
Assert.True(ContractValidation.AreProtocolsCompatible(7, 7));
Assert.False(ContractValidation.AreProtocolsCompatible(7, 8));
}
[Fact]
public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
{
Assert.True(ContractJson.Options.IsReadOnly);
Assert.Throws<InvalidOperationException>(() =>
ContractJson.Options.WriteIndented = true);
}
}
@@ -0,0 +1,30 @@
namespace FinalFactory.Rendezvous.Tests.Contracts;
internal static class ContractTestFiles
{
public static string Read(string fileName) => File
.ReadAllText(Path.Combine(Directory, fileName))
.TrimEnd('\r', '\n');
public static string Directory
{
get
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string solution = Path.Combine(directory.FullName, "Rendezvous.slnx");
if (File.Exists(solution))
{
return Path.Combine(
directory.FullName,
"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1");
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate contract test data.");
}
}
}
@@ -0,0 +1,68 @@
using System.Text.Json;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class OpenApiCompatibilityTests
{
private static readonly string[] ExpectedPaths =
[
"/health/live",
"/health/ready",
"/v1/join-attempts",
"/v1/join-attempts/{attemptId}/outcome",
"/v1/sessions",
"/v1/sessions/{listingId}",
"/v1/sessions/{listingId}/join-attempts",
"/v1/sessions/{listingId}/renew",
];
private static readonly string[] ExpectedListingProperties =
[
"buildVersion",
"capacity",
"contractVersion",
"displayName",
"environmentId",
"gameId",
"listingId",
"metadata",
"protocolVersion",
"publisherTrustMode",
"regionId",
"visibility",
];
[Fact]
public void GeneratedOpenApiContainsTheFrozenV1Surface()
{
string path = Path.Combine(
ContractTestFiles.Directory,
"../../../../../docs/api/rendezvous-v1.json");
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(Path.GetFullPath(path)));
JsonElement root = document.RootElement;
Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
string[] paths = root.GetProperty("paths")
.EnumerateObject()
.Select(static item => item.Name)
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(ExpectedPaths, paths);
JsonElement schemas = root.GetProperty("components").GetProperty("schemas");
Assert.Equal("string", schemas.GetProperty("GameId").GetProperty("type").GetString());
Assert.Equal("uuid", schemas.GetProperty("SessionListingId").GetProperty("format").GetString());
string[] listingProperties = schemas.GetProperty("SessionListing")
.GetProperty("properties")
.EnumerateObject()
.Select(static item => item.Name)
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(ExpectedListingProperties, listingProperties);
Assert.DoesNotContain(listingProperties, static property =>
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,103 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class PublicApiCompatibilityTests
{
[Fact]
public void ContractsPublicApiMatchesTheV1Snapshot()
{
string snapshot = CreateSnapshot(typeof(ContractLimits).Assembly);
string expected = ContractTestFiles.Read("contracts-public-api.txt");
if (expected == "SNAPSHOT_PENDING"
&& Environment.GetEnvironmentVariable("RENDEZVOUS_UPDATE_CONTRACT_SNAPSHOT") == "1")
{
string snapshotPath = Path.Combine(
ContractTestFiles.Directory,
"contracts-public-api.txt");
File.WriteAllText(snapshotPath, snapshot + Environment.NewLine);
expected = snapshot;
}
Assert.Equal(expected, snapshot);
}
private static string CreateSnapshot(Assembly assembly)
{
List<string> lines = [];
foreach (Type type in assembly.GetExportedTypes().OrderBy(static type => type.FullName, StringComparer.Ordinal))
{
lines.Add($"TYPE {FormatType(type)}");
if (type.IsEnum)
{
foreach (string name in Enum.GetNames(type))
{
object value = Enum.Parse(type, name);
lines.Add($" ENUM {name}={Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture)}");
}
continue;
}
foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.OrderBy(static field => field.Name, StringComparer.Ordinal))
{
string value = field.IsLiteral
? Convert.ToString(field.GetRawConstantValue(), System.Globalization.CultureInfo.InvariantCulture) ?? "null"
: "non-literal";
lines.Add($" FIELD {FormatType(field.FieldType)} {field.Name}={value}");
}
foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.Public | BindingFlags.Instance)
.OrderBy(static constructor => FormatParameters(constructor.GetParameters()), StringComparer.Ordinal))
{
lines.Add($" CTOR ({FormatParameters(constructor.GetParameters())})");
}
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.OrderBy(static property => property.Name, StringComparer.Ordinal))
{
string accessors = $"{(property.CanRead ? "get;" : string.Empty)}{(property.CanWrite ? "set;" : string.Empty)}";
lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}");
}
foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Where(static method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal))
.OrderBy(static method => method.Name, StringComparer.Ordinal)
.ThenBy(static method => FormatParameters(method.GetParameters()), StringComparer.Ordinal))
{
lines.Add($" METHOD {FormatType(method.ReturnType)} {method.Name}({FormatParameters(method.GetParameters())})");
}
}
return string.Join('\n', lines);
}
private static string FormatParameters(ParameterInfo[] parameters) => string.Join(
", ",
parameters.Select(static parameter =>
$"{FormatType(parameter.ParameterType)} {parameter.Name}"));
private static string FormatType(Type type)
{
if (type.IsByRef)
{
return $"{FormatType(type.GetElementType()!)}&";
}
if (type.IsArray)
{
return $"{FormatType(type.GetElementType()!)}[]";
}
if (type.IsGenericType)
{
string name = type.GetGenericTypeDefinition().FullName!;
name = name[..name.IndexOf('`')];
return $"{name}<{string.Join(",", type.GetGenericArguments().Select(FormatType))}>";
}
return type.FullName ?? type.Name;
}
}
@@ -0,0 +1,101 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class UdpCodecTests
{
private static readonly Guid Handle = new("00112233-4455-6677-8899-aabbccddeeff");
[Fact]
public void HostPresenceMatchesTheV1GoldenVector()
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
Assert.Equal(ContractTestFiles.Read("udp-host-ipv4.hex"), Convert.ToHexString(encoded).ToLowerInvariant());
Assert.True(RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? decoded, out UdpDecodeError error));
Assert.Equal(UdpDecodeError.None, error);
Assert.NotNull(decoded);
Assert.Equal(Handle, decoded.MediationHandle.Value);
Assert.Equal("192.0.2.10", decoded.LocalAddress);
Assert.Equal(9050, decoded.LocalPort);
Assert.Equal("Abc_123-xYz", decoded.Capability);
}
[Fact]
public void Ipv6RoundtripsWithoutLosingItsAddressFamily()
{
PresenceDatagram original = CreateDatagram();
original.MessageType = UdpPresenceMessageType.ClientPresence;
original.AddressFamily = AddressFamilyKind.Ipv6;
original.LocalAddress = "2001:db8::10";
byte[] encoded = RendezvousUdpCodec.Encode(original);
Assert.True(RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? decoded, out _));
Assert.NotNull(decoded);
Assert.Equal(AddressFamilyKind.Ipv6, decoded.AddressFamily);
Assert.Equal("2001:db8::10", decoded.LocalAddress);
}
[Fact]
public void EncoderRejectsAnAddressFamilyMismatch()
{
PresenceDatagram datagram = CreateDatagram();
datagram.AddressFamily = AddressFamilyKind.Ipv4;
datagram.LocalAddress = "2001:db8::10";
Assert.Throws<ArgumentException>(() => RendezvousUdpCodec.Encode(datagram));
}
[Theory]
[InlineData(2, 2, UdpDecodeError.UnsupportedVersion)]
[InlineData(3, 3, UdpDecodeError.UnknownMessageType)]
[InlineData(4, 1, UdpDecodeError.InvalidFlags)]
[InlineData(21, 5, UdpDecodeError.InvalidAddressFamily)]
public void DecoderReturnsStableErrorsForUnknownHeaderValues(
int offset,
byte replacement,
UdpDecodeError expected)
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
encoded[offset] = replacement;
Assert.False(RendezvousUdpCodec.TryDecode(encoded, out _, out UdpDecodeError actual));
Assert.Equal(expected, actual);
}
[Fact]
public void DecoderRejectsTruncationTrailingDataAndOversizedPackets()
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
byte[] trailing = [.. encoded, 0];
byte[] oversized = new byte[ContractLimits.UdpDatagramMaxBytes + 1];
Assert.False(RendezvousUdpCodec.TryDecode(encoded.AsSpan(0, encoded.Length - 1), out _, out UdpDecodeError truncated));
Assert.Equal(UdpDecodeError.Truncated, truncated);
Assert.False(RendezvousUdpCodec.TryDecode(trailing, out _, out UdpDecodeError trailingError));
Assert.Equal(UdpDecodeError.TrailingData, trailingError);
Assert.False(RendezvousUdpCodec.TryDecode(oversized, out _, out UdpDecodeError oversizedError));
Assert.Equal(UdpDecodeError.DatagramTooLarge, oversizedError);
}
[Fact]
public void DecoderRejectsNonBase64UrlCapabilities()
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
encoded[^1] = (byte)'+';
Assert.False(RendezvousUdpCodec.TryDecode(encoded, out _, out UdpDecodeError error));
Assert.Equal(UdpDecodeError.InvalidCapability, error);
}
private static PresenceDatagram CreateDatagram() => new()
{
MessageType = UdpPresenceMessageType.HostPresence,
MediationHandle = new MediationHandle(Handle),
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.0.2.10",
LocalPort = 9050,
Capability = "Abc_123-xYz",
};
}
@@ -0,0 +1,83 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class GamePolicyTests
{
[Fact]
public void RegistryFailsClosedForUnknownAndDisabledScopes()
{
GamePolicyRegistry registry = GamePolicyRegistry.Create(
[
ProvisioningTestData.CreatePolicy(),
ProvisioningTestData.CreatePolicy("unscouted", "staging", enabled: false),
]);
Assert.True(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("production"),
out _));
Assert.False(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("staging"),
out _));
Assert.False(registry.TryGet(
new GameId("unscouted"),
new EnvironmentId("staging"),
out _));
}
[Fact]
public void PerGamePolicyConstrainsProtocolMetadataQuotasAndFeatures()
{
GamePolicyRegistry registry = GamePolicyRegistry.Create(
[ProvisioningTestData.CreatePolicy()]);
Assert.True(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("production"),
out GamePolicy? policy));
Assert.NotNull(policy);
Assert.True(policy.AllowsProtocol(7));
Assert.False(policy.AllowsProtocol(8));
Assert.True(policy.AllowsRegion(new RegionId("eu-central")));
Assert.False(policy.AllowsRegion(new RegionId("us-east")));
Assert.True(policy.AllowsVisibility(ListingVisibility.Public));
Assert.True(policy.AllowsPublisherTrust(PublisherTrustMode.PlayerGrant));
Assert.Equal(FallbackPolicyMode.DedicatedEndpointAllowed, policy.FallbackPolicy);
Assert.Equal(10, policy.MaxListingsPerPrincipal);
Assert.Equal(1, policy.MaxAnonymousListingsPerAddress);
Assert.Equal(100, policy.MaxActiveJoinAttempts);
Assert.True(policy.AllowsMetadata(new Dictionary<string, string>
{
["mode"] = "co-op",
["map"] = "europa",
}));
Assert.False(policy.AllowsMetadata(new Dictionary<string, string>
{
["map"] = "europa",
}));
Assert.False(policy.AllowsMetadata(new Dictionary<string, string>
{
["mode"] = "co-op",
["unknown"] = "value",
}));
}
[Fact]
public void InvalidOrDuplicatePolicyConfigurationFailsAtStartup()
{
GamePolicyOptions invalid = ProvisioningTestData.CreatePolicy();
invalid.ProtocolVersions = [];
Assert.Throws<ProvisioningConfigurationException>(() =>
GamePolicyRegistry.Create([invalid]));
Assert.Throws<ProvisioningConfigurationException>(() =>
GamePolicyRegistry.Create(
[
ProvisioningTestData.CreatePolicy(),
ProvisioningTestData.CreatePolicy(),
]));
}
}
@@ -0,0 +1,225 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PrincipalCredentialTests
{
[Fact]
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = CreateService(keys);
DedicatedPublisherPrincipal dedicated = ProvisioningTestData.CreateDedicatedPublisher();
PlayerHostGrantPrincipal playerGrant = new(
"host-grant-7",
ProvisioningTestData.Now.AddMinutes(5),
dedicated.GameId,
dedicated.EnvironmentId,
dedicated.AllowedRegions);
CredentialValidationResult dedicatedResult = service.Validate(
service.Issue(dedicated, ProvisioningTestData.Now),
ProvisioningTestData.Now);
CredentialValidationResult grantResult = service.Validate(
service.Issue(playerGrant, ProvisioningTestData.Now),
ProvisioningTestData.Now);
Assert.IsType<DedicatedPublisherPrincipal>(dedicatedResult.Principal);
Assert.IsType<PlayerHostGrantPrincipal>(grantResult.Principal);
}
[Fact]
public void WrongIssuerAndAudienceAreRejectedAfterSignatureValidation()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService issuer = CreateService(keys);
string token = issuer.Issue(
ProvisioningTestData.CreateDedicatedPublisher(),
ProvisioningTestData.Now);
PrincipalCredentialService wrongIssuer = new(
"other-issuer",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
PrincipalCredentialService wrongAudience = new(
"final-factory-rendezvous",
"other-audience",
TimeSpan.FromSeconds(30),
keys);
Assert.Equal(
CredentialValidationError.IssuerMismatch,
wrongIssuer.Validate(token, ProvisioningTestData.Now).Error);
Assert.Equal(
CredentialValidationError.AudienceMismatch,
wrongAudience.Validate(token, ProvisioningTestData.Now).Error);
}
[Fact]
public void ExpiryTamperingAndRevocationAreRejected()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = CreateService(keys);
string token = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(1)),
ProvisioningTestData.Now);
char replacement = token[^1] == 'A' ? 'B' : 'A';
string tampered = token[..^1] + replacement;
Assert.Equal(
CredentialValidationError.Expired,
service.Validate(token, ProvisioningTestData.Now.AddSeconds(91)).Error);
Assert.Equal(
CredentialValidationError.SignatureInvalid,
service.Validate(tampered, ProvisioningTestData.Now).Error);
Assert.True(keys.Revoke("key-1"));
Assert.Equal(
CredentialValidationError.KeyRevoked,
service.Validate(token, ProvisioningTestData.Now).Error);
}
[Fact]
public void KeyRotationHonorsOverlapAndRejectsRetiredKeys()
{
SigningKeyOptions oldKey = ProvisioningTestData.CreateKey(
"old-key",
"old-secret",
ProvisioningTestData.Now.AddHours(-1),
ProvisioningTestData.Now.AddMinutes(10),
ProvisioningTestData.Now.AddMinutes(60));
SigningKeyOptions newKey = ProvisioningTestData.CreateKey(
"new-key",
"new-secret",
ProvisioningTestData.Now.AddMinutes(10),
ProvisioningTestData.Now.AddHours(2),
ProvisioningTestData.Now.AddHours(3));
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets(
"old-secret",
"new-secret");
using SigningKeyRing keys = SigningKeyRing.Create([oldKey, newKey], secrets);
PrincipalCredentialService service = CreateService(keys);
string oldToken = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(50)),
ProvisioningTestData.Now);
Assert.True(service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid);
Assert.Equal(
CredentialValidationError.KeyRetired,
service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(61)).Error);
string newToken = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(90)),
ProvisioningTestData.Now.AddMinutes(20));
Assert.True(service.Validate(newToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid);
}
[Fact]
public void OperatorCredentialNeverBecomesAPublisherPrincipal()
{
SigningKeyOptions operatorKey = ProvisioningTestData.CreateKey(
credentialKinds: [PrincipalCredentialKind.Operator],
gameId: null,
environmentId: null);
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[operatorKey],
secrets);
PrincipalCredentialService service = CreateService(keys);
OperatorPrincipal operatorPrincipal = new(
"operator-1",
ProvisioningTestData.Now.AddMinutes(5),
new HashSet<OperatorPermission> { OperatorPermission.RotateKeys });
CredentialValidationResult result = service.Validate(
service.Issue(operatorPrincipal, ProvisioningTestData.Now),
ProvisioningTestData.Now);
Assert.IsType<OperatorPrincipal>(result.Principal);
Assert.IsNotAssignableFrom<IPublisherPrincipal>(result.Principal);
}
[Fact]
public void ConfiguredRevocationDoesNotRequireRetiredSecretMaterial()
{
SigningKeyOptions revoked = ProvisioningTestData.CreateKey(
"revoked-key",
"removed-secret",
revoked: true);
SigningKeyOptions active = ProvisioningTestData.CreateKey(
"active-key",
"active-secret");
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("active-secret");
using SigningKeyRing keys = SigningKeyRing.Create([revoked, active], secrets);
Assert.Equal(
VerificationKeyLookup.Revoked,
keys.FindVerificationKey("revoked-key", ProvisioningTestData.Now, out _));
Assert.True(keys.TryGetSigningKey(
ProvisioningTestData.Now,
PrincipalCredentialKind.DedicatedPublisher,
"space-game",
"production",
out SigningKey? signingKey));
Assert.Equal("active-key", signingKey?.KeyId);
}
[Fact]
public void SigningKeyAuthorityRejectsCrossGameClaimsEvenWithAValidSignature()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
Assert.Equal(
VerificationKeyLookup.Available,
keys.FindVerificationKey("key-1", ProvisioningTestData.Now, out SigningKey? signingKey));
Assert.NotNull(signingKey);
CredentialPayload payload = new()
{
Version = ContractLimits.ContractVersion,
Issuer = "final-factory-rendezvous",
Audience = "rendezvous-service",
Subject = "malicious-grant-issuer",
Kind = PrincipalCredentialKind.PlayerHostGrant,
GameId = "unscouted",
EnvironmentId = "production",
Regions = ["eu-central"],
IssuedAtUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(),
NotBeforeUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(),
ExpiresAtUnixSeconds = ProvisioningTestData.Now.AddMinutes(5).ToUnixTimeSeconds(),
Nonce = Guid.NewGuid().ToString("N"),
};
string encodedPayload = Base64Url.Encode(
System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string signedContent = $"rv1.key-1.{encodedPayload}";
string token = $"{signedContent}.{Base64Url.Encode(signingKey.Sign(signedContent))}";
CredentialValidationResult result = CreateService(keys).Validate(
token,
ProvisioningTestData.Now);
Assert.Equal(CredentialValidationError.KeyScopeMismatch, result.Error);
Assert.Null(result.Principal);
}
private static PrincipalCredentialService CreateService(SigningKeyRing keys) => new(
"final-factory-rendezvous",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
}
@@ -0,0 +1,97 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class ProvisioningSecurityTests
{
[Fact]
public void StartupFailsClearlyWhenKeyMaterialIsAbsent()
{
ProvisioningOptions options = ProvisioningTestData.CreateOptions(
ProvisioningTestData.CreateKey(secretReference: "missing-production-secret"));
using DictionarySecretProvider empty = ProvisioningTestData.CreateSecrets();
ProvisioningConfigurationException exception = Assert.Throws<ProvisioningConfigurationException>(
() => ProvisioningRuntime.Create(options, empty, ProvisioningTestData.Now));
Assert.Contains("key-1", exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain("missing-production-secret", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void StartupRequiresAnActivePublisherKeyForEveryEnabledPolicy()
{
ProvisioningOptions options = ProvisioningTestData.CreateOptions();
options.Games.Add(ProvisioningTestData.CreatePolicy("unscouted", "production"));
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
ProvisioningConfigurationException exception = Assert.Throws<ProvisioningConfigurationException>(
() => ProvisioningRuntime.Create(options, secrets, ProvisioningTestData.Now));
Assert.Contains("unscouted/production", exception.Message, StringComparison.Ordinal);
Assert.Contains("key", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void SecretMaterialCredentialsAndKeysAreRedactedFromDiagnostics()
{
byte[] knownSecret = Enumerable.Range(1, 32).Select(static value => (byte)value).ToArray();
string encodedSecret = Convert.ToBase64String(knownSecret);
using SecretMaterial material = new(knownSecret);
using DictionarySecretProvider secrets = new(new Dictionary<string, byte[]>
{
["secret-1"] = knownSecret,
});
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = new(
"final-factory-rendezvous",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
string token = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(),
ProvisioningTestData.Now);
CredentialValidationResult result = service.Validate(token, ProvisioningTestData.Now);
string diagnostics = string.Join(
'|',
material,
secrets,
keys,
service,
result);
Assert.DoesNotContain(encodedSecret, diagnostics, StringComparison.Ordinal);
Assert.DoesNotContain(token, diagnostics, StringComparison.Ordinal);
Assert.Contains("redacted", diagnostics, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PublicClientAndContractSurfacesContainNoProvisioningSecrets()
{
Type[] publicTypes = typeof(GameId).Assembly.GetExportedTypes()
.Concat(Assembly.Load("FinalFactory.Rendezvous.Client").GetExportedTypes())
.ToArray();
string[] forbiddenTerms =
[
"GameSecret",
"SigningKey",
"KeyMaterial",
"PublisherCredential",
"SecretProvider",
];
foreach (Type type in publicTypes)
{
IEnumerable<string> names = type
.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
.Select(static member => member.Name)
.Append(type.Name);
Assert.DoesNotContain(names, name => forbiddenTerms.Any(term =>
name.Contains(term, StringComparison.OrdinalIgnoreCase)));
}
}
}
@@ -0,0 +1,98 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
internal static class ProvisioningTestData
{
public static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
public static GamePolicyOptions CreatePolicy(
string gameId = "space-game",
string environmentId = "production",
bool enabled = true) => new()
{
GameId = gameId,
EnvironmentId = environmentId,
Enabled = enabled,
ProtocolVersions = [7],
Regions = ["eu-central"],
VisibilityModes = [ListingVisibility.Public, ListingVisibility.Unlisted],
PublisherTrustModes =
[
PublisherTrustMode.ManagedDedicated,
PublisherTrustMode.PlayerGrant,
PublisherTrustMode.AnonymousUnlisted,
],
MetadataValueMaxBytes = new Dictionary<string, int>(StringComparer.Ordinal)
{
["map"] = 32,
["mode"] = 16,
},
RequiredMetadataKeys = ["mode"],
MetadataMaxBytes = 256,
MetadataMaxKeys = 2,
MaxListingsPerPrincipal = 10,
MaxAnonymousListingsPerAddress = 1,
MaxActiveJoinAttempts = 100,
FallbackPolicy = FallbackPolicyMode.DedicatedEndpointAllowed,
};
public static SigningKeyOptions CreateKey(
string keyId = "key-1",
string secretReference = "secret-1",
DateTimeOffset? notBefore = null,
DateTimeOffset? signUntil = null,
DateTimeOffset? verifyUntil = null,
bool revoked = false,
IEnumerable<PrincipalCredentialKind>? credentialKinds = null,
string? gameId = "space-game",
string? environmentId = "production") => new()
{
KeyId = keyId,
SecretReference = secretReference,
CredentialKinds = credentialKinds?.ToList()
?? [
PrincipalCredentialKind.DedicatedPublisher,
PrincipalCredentialKind.PlayerHostGrant,
],
GameId = gameId,
EnvironmentId = environmentId,
NotBefore = notBefore ?? Now.AddHours(-1),
SignUntil = signUntil ?? Now.AddHours(1),
VerifyUntil = verifyUntil ?? Now.AddHours(2),
Revoked = revoked,
};
public static DictionarySecretProvider CreateSecrets(params string[] references)
{
Dictionary<string, byte[]> secrets = new(StringComparer.Ordinal);
for (int index = 0; index < references.Length; index++)
{
secrets.Add(
references[index],
Enumerable.Range(1 + index, 32).Select(static value => (byte)value).ToArray());
}
return new DictionarySecretProvider(secrets);
}
public static DedicatedPublisherPrincipal CreateDedicatedPublisher(
DateTimeOffset? expiresAt = null,
string gameId = "space-game",
string environmentId = "production") => new(
"workload-42",
expiresAt ?? Now.AddMinutes(10),
new GameId(gameId),
new EnvironmentId(environmentId),
new HashSet<RegionId> { new("eu-central") });
public static ProvisioningOptions CreateOptions(SigningKeyOptions? key = null) => new()
{
Issuer = "final-factory-rendezvous",
Audience = "rendezvous-service",
ClockSkewSeconds = 30,
SigningKeys = [key ?? CreateKey()],
Games = [CreatePolicy()],
};
}
@@ -0,0 +1,124 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PublisherAuthorizationTests
{
private static readonly IReadOnlyDictionary<string, string> ValidMetadata =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
};
[Fact]
public void AuthoritativeScopeComesFromThePublisherPrincipal()
{
PublisherAuthorizationService service = CreateService();
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
PublisherAuthorizationResult result = service.Authorize(
principal,
new GameId("space-game"),
new EnvironmentId("production"),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.True(result.IsAllowed);
Assert.NotNull(result.Context);
Assert.Equal(principal.GameId, result.Context.GameId);
Assert.Equal(principal.EnvironmentId, result.Context.EnvironmentId);
}
[Theory]
[InlineData("unscouted", "production")]
[InlineData("space-game", "staging")]
public void CrossGameAndEnvironmentScopeEscalationIsDenied(
string requestedGame,
string requestedEnvironment)
{
PublisherAuthorizationResult result = CreateService().Authorize(
ProvisioningTestData.CreateDedicatedPublisher(),
new GameId(requestedGame),
new EnvironmentId(requestedEnvironment),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.False(result.IsAllowed);
Assert.Equal(PublisherAuthorizationError.ScopeMismatch, result.Error);
Assert.Null(result.Context);
}
[Fact]
public void OperatorCannotBeUsedAsAGamePublisher()
{
OperatorPrincipal principal = new(
"operator-1",
ProvisioningTestData.Now.AddMinutes(10),
new HashSet<OperatorPermission> { OperatorPermission.ReadPolicy });
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
new GameId("space-game"),
new EnvironmentId("production"),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.NotPublisher, result.Error);
}
[Fact]
public void AnonymousPublisherCanNeverEscalateToPublicVisibility()
{
AnonymousUnlistedPrincipal principal = new(
"anonymous-source-1",
ProvisioningTestData.Now.AddMinutes(2),
new GameId("space-game"),
new EnvironmentId("production"),
new HashSet<RegionId> { new("eu-central") });
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
principal.GameId,
principal.EnvironmentId,
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.AnonymousMustBeUnlisted, result.Error);
}
[Fact]
public void ExpiredPrincipalIsRecheckedAtAuthorizationTime()
{
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddSeconds(-1));
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
principal.GameId,
principal.EnvironmentId,
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.PrincipalExpired, result.Error);
}
private static PublisherAuthorizationService CreateService() => new(
GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]));
}
@@ -0,0 +1 @@
{"contractVersion":1,"code":"rateLimited","message":"Try again later.","correlationId":"request-001","retryAfterSeconds":3}
@@ -0,0 +1 @@
{"contractVersion":1,"items":[{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","publisherTrustMode":"managedDedicated","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}],"nextCursor":"cursor-002"}
@@ -0,0 +1,315 @@
TYPE FinalFactory.Rendezvous.Contracts.AddressFamilyKind
ENUM Ipv4=4
ENUM Ipv6=6
TYPE FinalFactory.Rendezvous.Contracts.ApiError
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Code {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String CorrelationId {get;set;}
PROP System.String Message {get;set;}
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.HostJoinAttempt> Items {get;set;}
PROP System.String NextCursor {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String Cursor {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP System.Int32 PageSize {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;}
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.RegionId> RegionId {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
PROP System.String NextCursor {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind
ENUM Connected=1
ENUM Cancelled=2
ENUM TimedOut=3
ENUM IncompatibleProtocol=4
ENUM StaleHost=5
ENUM ServiceRejected=6
ENUM HostRejected=7
ENUM TransportFailed=8
ENUM FallbackOffered=9
TYPE FinalFactory.Rendezvous.Contracts.ContractJson
PROP System.Text.Json.JsonSerializerOptions Options {get;}
METHOD System.Void Configure(System.Text.Json.JsonSerializerOptions options)
METHOD System.Text.Json.JsonSerializerOptions CreateOptions()
TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
FIELD System.Int32 BrowserPageMaxItems=100
FIELD System.Int32 BrowserResponseMaxBytes=262144
FIELD System.Int32 BuildVersionMaxBytes=64
FIELD System.Int32 ConnectionTicketMaxCharacters=192
FIELD System.Int32 ContractVersion=1
FIELD System.Int32 CursorMaxCharacters=512
FIELD System.Int32 DiagnosticCodeMaxCharacters=64
FIELD System.Int32 DisplayNameMaxBytes=128
FIELD System.Int32 EnvironmentIdMaxCharacters=32
FIELD System.Int32 ErrorMessageMaxBytes=256
FIELD System.Int32 GameIdMaxCharacters=64
FIELD System.Int32 HttpRequestMaxBytes=16384
FIELD System.Int32 IdempotencyKeyMaxCharacters=64
FIELD System.Int32 LiteNetLibNatTokenMaxCharacters=256
FIELD System.Int32 MetadataKeyMaxBytes=64
FIELD System.Int32 MetadataMaxBytes=4096
FIELD System.Int32 MetadataMaxKeys=32
FIELD System.Int32 MetadataValueMaxBytes=256
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
FIELD System.Int32 RegionIdMaxCharacters=32
FIELD System.Int32 SessionCapacityMaxPlayers=10000
FIELD System.Int32 UdpCapabilityMaxCharacters=192
FIELD System.Int32 UdpDatagramMaxBytes=1200
TYPE FinalFactory.Rendezvous.Contracts.ContractValidation
METHOD System.Boolean AreProtocolsCompatible(System.UInt32 requested, System.UInt32 offered)
METHOD System.Boolean IsBrowserResponseSizeValid(System.Int32 byteCount)
METHOD System.Boolean IsBuildVersionValid(System.String value)
METHOD System.Boolean IsCapabilityValid(System.String capability)
METHOD System.Boolean IsCapacityValid(FinalFactory.Rendezvous.Contracts.SessionCapacity capacity)
METHOD System.Boolean IsConnectionTicketValid(System.String ticket)
METHOD System.Boolean IsCursorValid(System.String value)
METHOD System.Boolean IsDiagnosticCodeValid(System.String value)
METHOD System.Boolean IsDisplayNameValid(System.String value)
METHOD System.Boolean IsHttpRequestSizeValid(System.Int32 byteCount)
METHOD System.Boolean IsIdempotencyKeyValid(System.String value)
METHOD System.Boolean IsMetadataValid(System.Collections.Generic.IReadOnlyDictionary<System.String,System.String> metadata)
METHOD System.Boolean IsNetworkEndpointValid(FinalFactory.Rendezvous.Contracts.NetworkEndpoint endpoint)
METHOD System.Boolean IsOpaqueHttpCredentialValid(System.String value)
METHOD System.Boolean IsPageSizeValid(System.Int32 pageSize)
METHOD System.Boolean IsUtf8LengthWithin(System.String value, System.Int32 maximumBytes)
METHOD FinalFactory.Rendezvous.Contracts.RendezvousErrorCode ValidateContractVersion(System.Int32 contractVersion)
TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP System.String IdempotencyKey {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ClientPunchCapability {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.DeleteSessionRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String LeaseToken {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.EnvironmentId
CTOR (System.String value)
PROP System.String Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.EnvironmentId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.EnvironmentId& environmentId)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.EnvironmentId left, FinalFactory.Rendezvous.Contracts.EnvironmentId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.EnvironmentId left, FinalFactory.Rendezvous.Contracts.EnvironmentId right)
TYPE FinalFactory.Rendezvous.Contracts.GameId
CTOR (System.String value)
PROP System.String Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.GameId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.GameId& gameId)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.GameId left, FinalFactory.Rendezvous.Contracts.GameId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.GameId left, FinalFactory.Rendezvous.Contracts.GameId right)
TYPE FinalFactory.Rendezvous.Contracts.GetSessionResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListing Session {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.HealthResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String Status {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.HostJoinAttempt
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP System.String HostPunchCapability {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.JoinAttemptId
CTOR (System.Guid value)
PROP System.Guid Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.JoinAttemptId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.JoinAttemptId& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.JoinAttemptId left, FinalFactory.Rendezvous.Contracts.JoinAttemptId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.JoinAttemptId left, FinalFactory.Rendezvous.Contracts.JoinAttemptId right)
TYPE FinalFactory.Rendezvous.Contracts.LeaseId
CTOR (System.Guid value)
PROP System.Guid Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.LeaseId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.LeaseId& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.LeaseId left, FinalFactory.Rendezvous.Contracts.LeaseId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.LeaseId left, FinalFactory.Rendezvous.Contracts.LeaseId right)
TYPE FinalFactory.Rendezvous.Contracts.ListingVisibility
ENUM Public=1
ENUM Unlisted=2
TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
CTOR (System.Guid value)
PROP System.Guid Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.MediationHandle other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
TYPE FinalFactory.Rendezvous.Contracts.NetworkEndpoint
CTOR ()
PROP System.String Address {get;set;}
PROP FinalFactory.Rendezvous.Contracts.AddressFamilyKind AddressFamily {get;set;}
PROP System.Int32 Port {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.PresenceDatagram
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.AddressFamilyKind AddressFamily {get;set;}
PROP System.String Capability {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String LocalAddress {get;set;}
PROP System.Int32 LocalPort {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
PROP FinalFactory.Rendezvous.Contracts.UdpPresenceMessageType MessageType {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.PublisherTrustMode
ENUM ManagedDedicated=1
ENUM PlayerGrant=2
ENUM AnonymousUnlisted=3
TYPE FinalFactory.Rendezvous.Contracts.RegionId
CTOR (System.String value)
PROP System.String Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.RegionId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.RegionId& regionId)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.RegionId left, FinalFactory.Rendezvous.Contracts.RegionId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.RegionId left, FinalFactory.Rendezvous.Contracts.RegionId right)
TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionRequest
CTOR ()
PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String DisplayName {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP System.String IdempotencyKey {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.RegionId RegionId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ListingVisibility Visibility {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP System.String HostPresenceCapability {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;set;}
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;}
PROP System.String LeaseToken {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.RendezvousErrorCode
ENUM None=0
ENUM InvalidRequest=1
ENUM UnsupportedContractVersion=2
ENUM IncompatibleProtocol=3
ENUM AuthenticationRequired=4
ENUM Forbidden=5
ENUM NotFound=6
ENUM Conflict=7
ENUM RateLimited=8
ENUM StaleHost=9
ENUM Expired=10
ENUM ReplayRejected=11
ENUM CapacityExceeded=12
ENUM ServiceUnavailable=13
ENUM InternalError=14
TYPE FinalFactory.Rendezvous.Contracts.RendezvousUdpCodec
FIELD System.Byte FlagsNone=0
FIELD System.Byte MagicFirst=82
FIELD System.Byte MagicSecond=86
METHOD System.Byte[] Encode(FinalFactory.Rendezvous.Contracts.PresenceDatagram datagram)
METHOD System.Boolean TryDecode(System.ReadOnlySpan<System.Byte> encoded, FinalFactory.Rendezvous.Contracts.PresenceDatagram& datagram, FinalFactory.Rendezvous.Contracts.UdpDecodeError& error)
TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String LeaseToken {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String DiagnosticCode {get;set;}
PROP System.Int32 ElapsedMilliseconds {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Outcome {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse
CTOR ()
PROP System.Boolean Accepted {get;set;}
PROP System.Int32 ContractVersion {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.SessionCapacity
CTOR ()
PROP System.Int32 CurrentPlayers {get;set;}
PROP System.Int32 MaximumPlayers {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.SessionListing
CTOR ()
PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String DisplayName {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.PublisherTrustMode PublisherTrustMode {get;set;}
PROP FinalFactory.Rendezvous.Contracts.RegionId RegionId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ListingVisibility Visibility {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.SessionListingId
CTOR (System.Guid value)
PROP System.Guid Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.SessionListingId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.SessionListingId& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
TYPE FinalFactory.Rendezvous.Contracts.UdpDecodeError
ENUM None=0
ENUM DatagramTooLarge=1
ENUM Truncated=2
ENUM InvalidMagic=3
ENUM UnsupportedVersion=4
ENUM UnknownMessageType=5
ENUM InvalidFlags=6
ENUM InvalidHandle=7
ENUM InvalidAddressFamily=8
ENUM InvalidAddress=9
ENUM InvalidPort=10
ENUM InvalidCapability=11
ENUM TrailingData=12
TYPE FinalFactory.Rendezvous.Contracts.UdpPresenceMessageType
ENUM HostPresence=1
ENUM ClientPresence=2
TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest
CTOR ()
PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String DisplayName {get;set;}
PROP System.String LeaseToken {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
@@ -0,0 +1 @@
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}
@@ -0,0 +1 @@
{"contractVersion":1,"idempotencyKey":"register-001","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}
@@ -0,0 +1 @@
525601010000112233445566778899aabbccddeeff0404c000020a235a0b4162635f3132332d78597a
@@ -107,7 +107,8 @@
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )"
"LiteNetLib": "[2.1.4, )",
"Microsoft.AspNetCore.OpenApi": "[10.0.9, )"
}
},
"finalfactory.rendezvous.testclient": {
@@ -123,6 +124,21 @@
"requested": "[2.1.4, )",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "CentralTransitive",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "1ihb8FO9cGgEK1/m3CTtT/SfnynwmiZib0W2pcDVj3KSWk/Sca4VOXEtaptKQc582zpFrzTFiwkGRCglt6H+WQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.OpenApi": {
"type": "CentralTransitive",
"requested": "[2.7.5, )",
"resolved": "2.7.5",
"contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w=="
}
}
}