Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1baa1055dc | |||
| 06c3973ce7 | |||
| a9a2b3db35 | |||
| 49564c7e7e | |||
| 02ca502a76 | |||
| 47382ddadc | |||
| 69c8b2d2bc |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -64,7 +64,7 @@ them but must not raise them without security review.
|
||||
| Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort |
|
||||
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
|
||||
| Opaque HTTP credential | 1,024 bytes encoded |
|
||||
| UDP capability or ticket | 768 bytes encoded, with the complete datagram still at most 1,200 bytes |
|
||||
| UDP capability or connection ticket | 192 base64url characters; NAT punch capabilities also remain below LiteNetLib's 256-character token ceiling; complete datagram at most 1,200 bytes |
|
||||
| Clock skew | 30 seconds maximum when validating issued/not-before/expiry times |
|
||||
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
|
||||
| Host presence freshness | 20 seconds |
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# ADR 0004: atomic ephemeral state and single-active availability
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-16
|
||||
- Tracking: #6
|
||||
|
||||
## Context
|
||||
|
||||
Listings, leases, endpoint observations, join attempts, and replay decisions must
|
||||
move together. A partially committed authorization can expose an expired listing,
|
||||
reuse a capability, or introduce an endpoint that was never authorized. V1 is a
|
||||
single-active service, so it needs honest bounded in-memory behavior rather than
|
||||
a database-shaped abstraction that implies unavailable durability or scale.
|
||||
|
||||
## Decision
|
||||
|
||||
`IEphemeralRendezvousStore` is the atomic boundary for directory, lease, presence,
|
||||
attempt, endpoint, replay, revocation, and drain transitions. The v1 implementation
|
||||
serializes each transition under one process-local lock. This deliberately favors
|
||||
simple, auditable correctness at the initial 25,000-listing/10,000-attempt ceiling.
|
||||
It retains only immutable listing data, opaque credential fingerprints, observed
|
||||
endpoints, monotonic deadlines, and bounded idempotency/replay records.
|
||||
|
||||
Every collection has an independent configured ceiling. An operation checks all
|
||||
of the capacity it needs before changing any collection. Exhaustion returns
|
||||
`CapacityExceeded`; it does not evict live state, partially insert an operation,
|
||||
or grow a fallback queue. Policy-provided per-owner listing and per-tenant active
|
||||
attempt quotas are evaluated inside the same creation transition, so concurrent
|
||||
requests cannot pass a check performed outside the store. New join authorization returns `ServiceUnavailable`
|
||||
when the atomic store is unavailable and `Draining` once drain starts.
|
||||
|
||||
### Time and cleanup
|
||||
|
||||
Expiry uses an injected monotonic clock. Wall time is used only to return an
|
||||
informational `ExpiresAt` value. Moving the wall clock forward or backward cannot
|
||||
expire or prolong authority. Cleanup runs deterministically at the start of every
|
||||
store operation and removes presence, attempts, listings, replay entries,
|
||||
idempotency records, and revocations at their deadline. Removal of a listing also
|
||||
removes its presence handle and every linked attempt before another caller can
|
||||
observe the store.
|
||||
|
||||
### Concurrency and idempotency
|
||||
|
||||
- Listing registration and join-attempt creation use a tenant-and-owner-scoped idempotency
|
||||
key plus a canonical request fingerprint. An exact duplicate returns the
|
||||
original live result; reuse with different input returns `Conflict`; replay
|
||||
after the resource has expired returns `Expired` until the bounded idempotency
|
||||
record itself expires. Configuration requires idempotency retention to cover
|
||||
every listing and attempt lifetime, preventing a live duplicate after eviction.
|
||||
- Lease renewal is compare-and-swap by version. A stale renewal returns the latest
|
||||
version as `Conflict`. Renew/delete races are serialized: renewal either commits
|
||||
before deletion or observes the listing as absent.
|
||||
- Host presence refresh is an atomic whole-endpoint replacement because NAT
|
||||
mappings can legitimately change. Attempt capabilities are different: the
|
||||
first endpoint bound for each role wins, an identical datagram is idempotent,
|
||||
and a different replay is rejected. Introduction is consumed once atomically.
|
||||
- Cancellation is checked before waiting for the lock and again after acquiring
|
||||
it. A cancellation observed at either point makes no change. Once a synchronous
|
||||
transition starts, it completes atomically and does not expose partial state.
|
||||
|
||||
### Visibility and revocation
|
||||
|
||||
A listing is visible or joinable only when its lease and authenticated UDP host
|
||||
presence are both fresh. Public browsing is tenant/protocol scoped, excludes
|
||||
unlisted sessions, and uses a stable listing-ID order with the contract page
|
||||
ceiling. Revoking a listing or principal removes every listing, presence, and
|
||||
attempt path in the same transition. A revocation is inserted before removal;
|
||||
if the bounded revocation pool is full, the operation rejects without deleting
|
||||
anything.
|
||||
|
||||
### Restart and graceful drain
|
||||
|
||||
A process restart creates a new store instance ID and starts empty. Old listing,
|
||||
lease, attempt, endpoint, idempotency, and consumption state is not recovered.
|
||||
Publishers must re-register; old callers receive typed `NotFound`, `Expired`, or
|
||||
`ServiceUnavailable` outcomes rather than an ambiguous success. No database is
|
||||
required or supported for the single-active MVP.
|
||||
|
||||
Drain is idempotent. It immediately rejects new registrations, attempts, and
|
||||
lease extensions, while already-created attempts may bind endpoints and consume
|
||||
their introduction during the configured window (at most 30 seconds). At the
|
||||
deadline all active state is cleared atomically. Readiness is false while draining
|
||||
or unavailable, and application shutdown starts drain before teardown.
|
||||
|
||||
## Future shared-store mapping
|
||||
|
||||
The interface uses explicit typed outcomes, TTLs, compare-and-swap versions,
|
||||
idempotency records, and all-or-nothing multi-record transitions. A future Redis
|
||||
implementation therefore requires authenticated transport, tenant-prefixed keys,
|
||||
server-side scripts or transactions for each transition, TTLs based on the store's
|
||||
authoritative time, and deterministic mediator routing. It must preserve these
|
||||
semantics and pass the same contract tests before issue #18 may enable more than
|
||||
one active instance.
|
||||
|
||||
## Consequences
|
||||
|
||||
- V1 has deterministic failure and restart behavior without durable gameplay state.
|
||||
- A single lock is a measured capacity constraint, not a claim of horizontal scale.
|
||||
- Transport and HTTP modules cannot bypass the store for authorization decisions.
|
||||
- Operational code must treat `CapacityExceeded`, `Draining`, and
|
||||
`ServiceUnavailable` as normal typed overload/availability outcomes.
|
||||
@@ -0,0 +1,91 @@
|
||||
# ADR 0005: authenticated session lease and presence lifecycle
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-16
|
||||
- Tracking: #7
|
||||
|
||||
## Context
|
||||
|
||||
A host needs to publish a player-facing session without letting an HTTP request
|
||||
claim a public endpoint or remain visible after the gameplay socket disappears.
|
||||
Registration retries must be safe, credentials must remain opaque, and policy or
|
||||
ownership checks cannot race state mutation.
|
||||
|
||||
## Decision
|
||||
|
||||
The four host HTTP operations require `Authorization: Bearer <publisher credential>`.
|
||||
The signed principal supplies the authoritative game, environment, publisher trust
|
||||
mode, subject, and allowed regions. Request fields never widen that scope. Creation
|
||||
and update apply the enabled `GamePolicy` to exact protocol, region, visibility,
|
||||
bounded display/build/capacity values, and the allowlisted metadata schema.
|
||||
|
||||
Capacity reported by a host is advisory directory information. Rendezvous bounds
|
||||
and publishes it but never treats it as final admission authority; the game host
|
||||
still decides identity, bans, reserved slots, and whether a connection may join.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> AwaitingPresence: authorized register
|
||||
AwaitingPresence --> Listed: valid host UDP presence
|
||||
Listed --> AwaitingPresence: presence deadline passes
|
||||
AwaitingPresence --> AwaitingPresence: lease renew or data update
|
||||
Listed --> Listed: lease renew, data update, or presence refresh
|
||||
AwaitingPresence --> Removed: lease expiry or delete
|
||||
Listed --> Removed: lease expiry or delete
|
||||
Removed --> [*]
|
||||
```
|
||||
|
||||
Registration returns a listing ID, lease ID/token, host-presence handle/capability,
|
||||
lease expiry, a 30-second renewal suggestion, and a 10-second presence-refresh
|
||||
suggestion. The authoritative ceilings remain 60 seconds for the lease and 20
|
||||
seconds for presence. Timing suggestions are server-controlled, not client-selected.
|
||||
|
||||
The lease token and presence capability are 256-bit opaque values derived with
|
||||
HMAC-SHA256 from an in-memory per-process secret, a purpose label, the publisher
|
||||
subject, the idempotency key, a canonical request fingerprint, and a random
|
||||
per-registration derivation salt. Opaque IDs use separate purpose labels. Exact
|
||||
retries read the retained non-secret salt and therefore reproduce the original
|
||||
response without retaining plaintext credentials. Once the bounded idempotency
|
||||
record expires, a new salt rotates IDs and capabilities so an old token cannot
|
||||
regain authority. Metadata order is canonicalized before fingerprinting. The store
|
||||
retains the salt and only a second keyed fingerprint of each token. Restart rotates
|
||||
the derivation secret while the matching ephemeral state disappears.
|
||||
|
||||
Renew, update, and delete require both the same publisher subject and the lease
|
||||
capability. Cross-owner or wrong-capability access returns the same not-found shape.
|
||||
Update may change display name, build label, advisory capacity, and metadata only;
|
||||
game, environment, region, protocol, visibility, trust mode, and opaque IDs remain
|
||||
canonical. Delete is idempotent and does not reveal whether another publisher owns
|
||||
the supplied ID.
|
||||
|
||||
### UDP presence
|
||||
|
||||
Only a structurally valid `HostPresence` datagram with the issued capability can
|
||||
refresh presence. The public endpoint is the UDP packet's observed source on the
|
||||
host's gameplay socket; the HTTP API never accepts one. The bounded local candidate
|
||||
comes from the authenticated datagram. Invalid, unknown, or client-presence packets
|
||||
receive no response. Presence expiry demotes public visibility but keeps the lease,
|
||||
so the same handle can restore visibility without changing session identity.
|
||||
|
||||
Public listing responses contain bounded listing data only. They never contain
|
||||
public/local endpoints, lease tokens, presence capabilities, fingerprints, store
|
||||
keys, or canonical player identity.
|
||||
|
||||
## Failure semantics
|
||||
|
||||
- malformed or policy-invalid fields return a stable typed `InvalidRequest`;
|
||||
- an unsupported gameplay protocol returns `IncompatibleProtocol`;
|
||||
- missing/invalid publisher authentication returns `AuthenticationRequired`;
|
||||
- cross-scope authorization returns `Forbidden` without resource disclosure;
|
||||
- wrong owner/capability or expired state returns the tenant-hidden `NotFound`;
|
||||
- idempotency reuse with changed input returns `Conflict`;
|
||||
- publisher/global exhaustion returns `CapacityExceeded`; and
|
||||
- drain or loss of atomic state returns `ServiceUnavailable` and authorizes no join.
|
||||
|
||||
## Consequences
|
||||
|
||||
- HTTP registration alone can never make a public session browseable.
|
||||
- Plaintext session capabilities are returned to the intended host but are not
|
||||
retained, logged, included in public listing DTOs, or exported as metrics.
|
||||
- Re-registration after restart is the recovery path; there is no durable session
|
||||
identity or gameplay state in Rendezvous.
|
||||
@@ -0,0 +1,51 @@
|
||||
# ADR 0006: bounded compatible session browser
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-16
|
||||
- Tracking: #8
|
||||
|
||||
## Decision
|
||||
|
||||
The public list endpoint requires game, environment, and exact gameplay protocol.
|
||||
Region is optional, page size is 1–100, and callers may exclude sessions whose
|
||||
advisory current-player count has reached the advertised maximum. Lists contain
|
||||
public sessions only and only while both lease and authenticated host presence are
|
||||
fresh. Unlisted sessions never appear in a list; they may be retrieved directly by
|
||||
their 128-bit unguessable listing ID only when the caller also supplies the exact
|
||||
game, environment, and protocol scope.
|
||||
|
||||
Results use ascending opaque listing ID as a deterministic keyset. A cursor carries
|
||||
the last ID plus every compatibility/filter field, a five-minute expiry, and an
|
||||
HMAC-SHA256 signature under a per-process key. Tampering, expiry, or reuse with a
|
||||
different tenant/protocol/region/full filter returns `InvalidRequest`. Restart
|
||||
rotates the key, matching the loss of ephemeral listings.
|
||||
|
||||
Pagination is a bounded live view, not a database snapshot. A record that remains
|
||||
eligible and whose ID is greater than the cursor is returned exactly once. Records
|
||||
removed or made stale disappear immediately. A record created after a page whose ID
|
||||
sorts before that page's cursor is outside that traversal; callers refresh from the
|
||||
first page to discover new sessions. This avoids skips or duplicates among stable
|
||||
eligible records without retaining per-browser snapshot state.
|
||||
|
||||
The store reads at most page size plus one record. The service serializes against
|
||||
the 256 KiB response ceiling and shortens a page before returning it when metadata
|
||||
makes the requested count too large. A continuation cursor is emitted whenever an
|
||||
extra or byte-trimmed record remains. All cursor, page, metadata, property, scalar,
|
||||
and collection sizes are bounded before untrusted allocation can grow without a
|
||||
ceiling.
|
||||
|
||||
Browser DTOs are fresh copies containing only opaque listing ID, exact compatibility,
|
||||
region, visibility/trust presentation, advisory capacity, build/display labels, and
|
||||
policy-validated string metadata. They contain no observed endpoint, lease,
|
||||
capability, ticket, credential fingerprint, derivation salt, principal subject, or
|
||||
store key. Metadata is display text: JSON encoding escapes markup, but game UI must
|
||||
still render values as text and must never execute markup, interpret endpoints, or
|
||||
use metadata for authorization.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Cross-game, cross-environment, incompatible, stale, revoked, expired, unlisted,
|
||||
and optionally full sessions are removed before response construction.
|
||||
- Direct unlisted lookup is suitable for an out-of-band invite carrying the opaque
|
||||
ID; human join codes remain future work and require their own bounded abuse model.
|
||||
- Host capacity remains advisory. The host makes the final admission decision.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ADR 0007: caller-owned .NET publisher and browser SDK
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-16
|
||||
- Tracking: #9
|
||||
|
||||
## Decision
|
||||
|
||||
The .NET client package exposes separate publisher and browser interfaces plus
|
||||
concrete clients over a caller-supplied `HttpClient`. The caller owns that client,
|
||||
its handler, base address, connection pool, proxy, and lifetime. SDK operations
|
||||
dispose every request, response, and response body they create, but never dispose
|
||||
the supplied client. The package targets `netstandard2.1`, depends only on the
|
||||
wire-contract package and LiteNetLib, and contains no Godot types, global client,
|
||||
service URL, publisher secret, or embedded game credential.
|
||||
|
||||
Every operation returns `RendezvousClientResult<T>` with a stable error code,
|
||||
message, and optional retry guidance. Cancellation remains exceptional through
|
||||
the caller's `CancellationToken`; transport failures become `ServiceUnavailable`.
|
||||
Response bodies are streamed under the contract's 256 KiB browser ceiling before
|
||||
deserialization. Invalid or oversized success bodies become `InternalError` and
|
||||
never escape as partially trusted contract objects.
|
||||
|
||||
The SDK retries only operations whose duplicate execution is safe: scoped reads,
|
||||
idempotency-keyed registration, lease renewal with the same lease token, complete
|
||||
resource update, and lease-token deregistration. It honors bounded server retry
|
||||
guidance and otherwise uses capped exponential backoff with jitter. Each retry
|
||||
creates a fresh HTTP request while preserving the caller's registration
|
||||
idempotency key. Configuration is copied on construction so later option mutation
|
||||
cannot change an in-flight client's behavior.
|
||||
|
||||
`PublishedSession` holds the server-issued lease and presence capabilities needed
|
||||
by the host. Its string representation always redacts them. Update requests are
|
||||
copied before the lease token is attached, so the SDK never mutates caller-owned
|
||||
DTOs. The browser exposes one-page calls and bounded cursor traversal; cursor
|
||||
values remain opaque and caller requests remain unchanged.
|
||||
|
||||
Lease maintenance is explicit. Creating a `SessionLeaseMaintainer` starts no task;
|
||||
the game chooses when to call `RunAsync`, owns cancellation, and awaits
|
||||
`DisposeAsync`. The loop uses the latest server-provided renewal interval and
|
||||
returns a distinct cancelled, disposed, lost-lease, or failed result. Terminal
|
||||
authorization, expiry, and missing-lease responses also raise `LeaseLost` so the
|
||||
host can stop advertising or re-register deliberately.
|
||||
|
||||
## Consequences
|
||||
|
||||
- SpaceGame and Unscouted can inject the publisher/browser interfaces in tests
|
||||
without an engine runtime or real network.
|
||||
- Games must configure an absolute `HttpClient.BaseAddress` (or equivalent
|
||||
handler routing), obtain publisher credentials from their deployment boundary,
|
||||
and explicitly run and dispose lease maintenance.
|
||||
- The versioned client public-API snapshot and live-server integration tests fail
|
||||
together when SDK and HTTP contracts drift.
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADR 0008: scoped join attempts and one-time connection tickets
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-16
|
||||
- Tracking: #10
|
||||
|
||||
## Decision
|
||||
|
||||
Join creation is an unauthenticated public operation because v1 does not treat a
|
||||
Rendezvous caller as game identity. The HTTP source address is normalized and
|
||||
converted to a process-keyed opaque subject for idempotency and bounded policy
|
||||
accounting; raw addresses and the derived subject are never returned or logged.
|
||||
A successful request means only that this network client may try to connect to
|
||||
this active session. It does not reserve capacity or grant gameplay admission.
|
||||
|
||||
Creation validates the v1 contract, caller idempotency key, enabled tenant policy,
|
||||
exact gameplay protocol, listing scope, live lease, and fresh authenticated host
|
||||
presence in one atomic store operation. A listing advertised as full remains
|
||||
joinable because its player count is advisory and the game host owns the final
|
||||
capacity, identity, ban, and admission decision.
|
||||
|
||||
Each attempt derives independent host-punch, client-punch, and connection-ticket
|
||||
credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC
|
||||
key, the client subject, the complete canonical request fingerprint, a fresh salt,
|
||||
and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
|
||||
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
|
||||
256-character NAT token ceiling. State retains keyed credential fingerprints,
|
||||
derivation inputs, and salt—not issued plaintext. All diagnostic string
|
||||
representations redact credentials and derivation material.
|
||||
|
||||
The client receives only its punch capability. A host polls its own listing with
|
||||
the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
|
||||
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
|
||||
identical join request returns the same live attempt; changing the request under
|
||||
the same owner/key conflicts. A client may cancel with its punch capability in
|
||||
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the
|
||||
attempt. Listing deletion, expiry, revocation, or process restart removes every
|
||||
associated attempt and credential fingerprint.
|
||||
|
||||
Endpoint binding remains role- and capability-specific. The first endpoint
|
||||
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
|
||||
endpoint or role substitution is rejected. An introduction is consumable once
|
||||
only after both roles bind, so concurrent attempts for the same listing cannot
|
||||
cross-wire.
|
||||
|
||||
The connection ticket is distinct from both punch capabilities and is reproduced
|
||||
only after introduction succeeds. Its window begins at that moment and lasts at
|
||||
most 20 seconds without outliving the 30-second attempt. The server has an atomic
|
||||
fingerprint-consumption seam for mediator tests and revocation. On the game host,
|
||||
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
|
||||
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/
|
||||
revoked/replayed tickets, and zeroes retained digests and key material on disposal.
|
||||
Issue #11 carries the ticket in the authenticated introduction; issue #12 wires
|
||||
authorization and consumption into the caller-owned LiteNetLib coordinator.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A join attempt is transport authorization, never proof of player identity or a
|
||||
game slot.
|
||||
- Network-address-derived subjects are process-local abuse/idempotency scopes,
|
||||
not stable user identifiers; stronger authenticated player scopes require a
|
||||
future game-owned identity contract.
|
||||
- Cancellation after a ticket has reached a host must also revoke that host's
|
||||
local validator entry; coordinator wiring owns that race in issue #12.
|
||||
- Capability and ticket plaintext never enter browser results, state snapshots,
|
||||
logs, metrics, or generated string representations.
|
||||
@@ -6,8 +6,15 @@ decision requires a superseding ADR and corresponding contract/test updates.
|
||||
- [ADR 0001: v1 control-plane boundaries and domain](0001-v1-control-plane-boundaries.md)
|
||||
- [ADR 0002: publisher trust, discovery, compatibility, and fallback](0002-publisher-trust-and-connection-policy.md)
|
||||
- [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
|
||||
- [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md)
|
||||
- [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md)
|
||||
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md)
|
||||
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
|
||||
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,111 @@
|
||||
# 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. |
|
||||
| `DELETE` | `/v1/join-attempts/{attemptId}` | Cancel an attempt using its client punch capability. |
|
||||
| `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.
|
||||
|
||||
Attempt cancellation sends the short-lived client punch capability in
|
||||
`X-Rendezvous-Client-Punch-Capability`. Join creation uses the observed HTTP
|
||||
source only for a process-keyed, short-lived idempotency/abuse scope; this is not
|
||||
player authentication and is never returned to callers.
|
||||
|
||||
## 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 | 1–10,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.
|
||||
@@ -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, 1–65535. |
|
||||
| next | 1 | Capability length, 1–192. |
|
||||
| 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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public enum ConnectionTicketConsumptionResult
|
||||
{
|
||||
Accepted = 1,
|
||||
NotFound = 2,
|
||||
Expired = 3,
|
||||
Rejected = 4,
|
||||
AlreadyConsumed = 5,
|
||||
Revoked = 6,
|
||||
}
|
||||
|
||||
public sealed class ConnectionTicketValidator : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<JoinAttemptId, TicketEntry> _tickets = [];
|
||||
private readonly int _maximumAuthorizedTickets;
|
||||
private readonly IConnectionTicketClock _clock;
|
||||
private readonly byte[] _fingerprintKey = new byte[32];
|
||||
private bool _disposed;
|
||||
|
||||
public ConnectionTicketValidator(int maximumAuthorizedTickets = 1_024)
|
||||
: this(maximumAuthorizedTickets, new SystemConnectionTicketClock())
|
||||
{
|
||||
}
|
||||
|
||||
internal ConnectionTicketValidator(
|
||||
int maximumAuthorizedTickets,
|
||||
IConnectionTicketClock clock)
|
||||
{
|
||||
if (maximumAuthorizedTickets is < 1 or > 10_000)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maximumAuthorizedTickets));
|
||||
}
|
||||
|
||||
_maximumAuthorizedTickets = maximumAuthorizedTickets;
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
RandomNumberGenerator.Fill(_fingerprintKey);
|
||||
}
|
||||
|
||||
public bool TryAuthorize(
|
||||
JoinAttemptId attemptId,
|
||||
string connectionTicket,
|
||||
DateTimeOffset expiresAt)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
DateTimeOffset now = _clock.UtcNow;
|
||||
if (attemptId.Value == Guid.Empty
|
||||
|| !ContractValidation.IsConnectionTicketValid(connectionTicket)
|
||||
|| expiresAt <= now)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
RemoveExpired(now);
|
||||
byte[] fingerprint = Fingerprint(connectionTicket);
|
||||
if (_tickets.TryGetValue(attemptId, out TicketEntry? current))
|
||||
{
|
||||
bool idempotent = current.State == TicketState.Active
|
||||
&& current.ExpiresAt == expiresAt
|
||||
&& CryptographicOperations.FixedTimeEquals(current.Fingerprint, fingerprint);
|
||||
CryptographicOperations.ZeroMemory(fingerprint);
|
||||
return idempotent;
|
||||
}
|
||||
|
||||
if (_tickets.Count >= _maximumAuthorizedTickets)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(fingerprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
_tickets.Add(attemptId, new(fingerprint, expiresAt));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public ConnectionTicketConsumptionResult Consume(
|
||||
JoinAttemptId attemptId,
|
||||
string connectionTicket)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
DateTimeOffset now = _clock.UtcNow;
|
||||
if (attemptId.Value == Guid.Empty
|
||||
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||
{
|
||||
return ConnectionTicketConsumptionResult.Rejected;
|
||||
}
|
||||
|
||||
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
|
||||
{
|
||||
RemoveExpired(now);
|
||||
return ConnectionTicketConsumptionResult.NotFound;
|
||||
}
|
||||
|
||||
if (entry.ExpiresAt <= now)
|
||||
{
|
||||
Remove(attemptId, entry);
|
||||
return ConnectionTicketConsumptionResult.Expired;
|
||||
}
|
||||
|
||||
if (entry.State == TicketState.Revoked)
|
||||
{
|
||||
return ConnectionTicketConsumptionResult.Revoked;
|
||||
}
|
||||
|
||||
if (entry.State == TicketState.Consumed)
|
||||
{
|
||||
return ConnectionTicketConsumptionResult.AlreadyConsumed;
|
||||
}
|
||||
|
||||
byte[] supplied = Fingerprint(connectionTicket);
|
||||
bool matches = CryptographicOperations.FixedTimeEquals(entry.Fingerprint, supplied);
|
||||
CryptographicOperations.ZeroMemory(supplied);
|
||||
if (!matches)
|
||||
{
|
||||
return ConnectionTicketConsumptionResult.Rejected;
|
||||
}
|
||||
|
||||
entry.State = TicketState.Consumed;
|
||||
return ConnectionTicketConsumptionResult.Accepted;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Revoke(JoinAttemptId attemptId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
RemoveExpired(_clock.UtcNow);
|
||||
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.State = TicketState.Revoked;
|
||||
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TicketEntry entry in _tickets.Values)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||
}
|
||||
|
||||
_tickets.Clear();
|
||||
CryptographicOperations.ZeroMemory(_fingerprintKey);
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => "[ConnectionTicketValidator: tickets and key redacted]";
|
||||
|
||||
private byte[] Fingerprint(string ticket)
|
||||
{
|
||||
byte[] encoded = Encoding.ASCII.GetBytes(ticket);
|
||||
try
|
||||
{
|
||||
using HMACSHA256 hmac = new(_fingerprintKey);
|
||||
return hmac.ComputeHash(encoded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveExpired(DateTimeOffset now)
|
||||
{
|
||||
foreach (KeyValuePair<JoinAttemptId, TicketEntry> item in _tickets
|
||||
.Where(item => item.Value.ExpiresAt <= now)
|
||||
.ToArray())
|
||||
{
|
||||
Remove(item.Key, item.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private void Remove(JoinAttemptId attemptId, TicketEntry entry)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||
_tickets.Remove(attemptId);
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(ConnectionTicketValidator));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TicketEntry(byte[] fingerprint, DateTimeOffset expiresAt)
|
||||
{
|
||||
public byte[] Fingerprint { get; } = fingerprint;
|
||||
public DateTimeOffset ExpiresAt { get; } = expiresAt;
|
||||
public TicketState State { get; set; }
|
||||
}
|
||||
|
||||
private enum TicketState
|
||||
{
|
||||
Active = 0,
|
||||
Consumed = 1,
|
||||
Revoked = 2,
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IConnectionTicketClock
|
||||
{
|
||||
DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
|
||||
internal sealed class SystemConnectionTicketClock : IConnectionTicketClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -5,10 +5,12 @@
|
||||
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
||||
<PackageReference Include="LiteNetLib" />
|
||||
<None Update="README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
||||
@@ -0,0 +1,79 @@
|
||||
# FinalFactory.Rendezvous.Client
|
||||
|
||||
Godot-independent .NET publisher and session-browser SDK for Rendezvous v1.
|
||||
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
|
||||
|
||||
```csharp
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
using HttpClient http = new()
|
||||
{
|
||||
BaseAddress = new Uri("https://rendezvous.example/"),
|
||||
};
|
||||
|
||||
string publisherCredential = Environment.GetEnvironmentVariable(
|
||||
"RENDEZVOUS_PUBLISHER_CREDENTIAL")
|
||||
?? throw new InvalidOperationException("Publisher credential is not configured.");
|
||||
CancellationToken cancellationToken = default;
|
||||
RendezvousPublisherClient publisher = new(http);
|
||||
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||
new RegisterSessionRequest
|
||||
{
|
||||
IdempotencyKey = Guid.NewGuid().ToString("N"),
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.0.0",
|
||||
DisplayName = "My server",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||
},
|
||||
publisherCredential,
|
||||
cancellationToken);
|
||||
if (!registered.IsSuccess || registered.Value is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Registration failed: {registered.Error} ({registered.Message})");
|
||||
}
|
||||
```
|
||||
|
||||
Load `publisherCredential` from the game's deployment secret boundary; never
|
||||
embed it in a client build or source control. A successful registration returns a
|
||||
`PublishedSession` containing the lease and host-presence capabilities.
|
||||
|
||||
Lease renewal is explicit and caller-controlled:
|
||||
|
||||
```csharp
|
||||
PublishedSession session = registered.Value;
|
||||
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||
session,
|
||||
publisherCredential);
|
||||
LeaseMaintenanceResult stopped = await maintainer.RunAsync(cancellationToken);
|
||||
```
|
||||
|
||||
Creating the maintainer does not start background work. Await its run and dispose
|
||||
it when hosting stops. Use `IRendezvousPublisherClient` and
|
||||
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
|
||||
the requests and responses it creates but never disposes the supplied `HttpClient`.
|
||||
|
||||
The host-side `ConnectionTicketValidator` is a bounded, thread-safe one-time gate.
|
||||
Authorize only tickets delivered by the authenticated Rendezvous introduction,
|
||||
then consume the exact ticket presented by the direct LiteNetLib connection:
|
||||
|
||||
```csharp
|
||||
using ConnectionTicketValidator tickets = new();
|
||||
tickets.TryAuthorize(attemptId, expectedTicket, expiresAt);
|
||||
ConnectionTicketConsumptionResult admission = tickets.Consume(
|
||||
attemptId,
|
||||
presentedTicket);
|
||||
```
|
||||
|
||||
An `Accepted` ticket authorizes only this connection attempt. The game must still
|
||||
apply its own player identity, capacity, ban, and gameplay admission rules. Revoke
|
||||
the attempt on cancellation and dispose the validator during host shutdown so its
|
||||
keyed ticket digests are zeroed.
|
||||
|
||||
See the repository's ADR 0007 for HTTP ownership/retry semantics and ADR 0008 for
|
||||
join-capability and connection-ticket security semantics.
|
||||
@@ -0,0 +1,141 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class RendezvousClientResult<T>
|
||||
{
|
||||
internal RendezvousClientResult(
|
||||
RendezvousErrorCode error,
|
||||
T? value,
|
||||
string message,
|
||||
int? retryAfterSeconds)
|
||||
{
|
||||
Error = error;
|
||||
Value = value;
|
||||
Message = message;
|
||||
RetryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
public bool IsSuccess => Error == RendezvousErrorCode.None;
|
||||
public RendezvousErrorCode Error { get; }
|
||||
public T? Value { get; }
|
||||
public string Message { get; }
|
||||
public int? RetryAfterSeconds { get; }
|
||||
|
||||
}
|
||||
|
||||
public static class RendezvousClientResult
|
||||
{
|
||||
public static RendezvousClientResult<T> Success<T>(T value) =>
|
||||
value is null
|
||||
? throw new ArgumentNullException(nameof(value))
|
||||
: new(RendezvousErrorCode.None, value, string.Empty, null);
|
||||
|
||||
public static RendezvousClientResult<T> Failure<T>(
|
||||
RendezvousErrorCode error,
|
||||
string message,
|
||||
int? retryAfterSeconds = null) =>
|
||||
error == RendezvousErrorCode.None
|
||||
? throw new ArgumentException("A failure requires a non-success error.", nameof(error))
|
||||
: new(error, default, message ?? string.Empty, retryAfterSeconds);
|
||||
}
|
||||
|
||||
public sealed class PublishedSession
|
||||
{
|
||||
internal PublishedSession(RegisterSessionResponse response)
|
||||
{
|
||||
ListingId = response.ListingId;
|
||||
LeaseId = response.LeaseId;
|
||||
LeaseToken = response.LeaseToken;
|
||||
HostPresenceHandle = response.HostPresenceHandle;
|
||||
HostPresenceCapability = response.HostPresenceCapability;
|
||||
ExpiresAt = response.ExpiresAt;
|
||||
LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
|
||||
}
|
||||
|
||||
public SessionListingId ListingId { get; }
|
||||
public LeaseId LeaseId { get; }
|
||||
public string LeaseToken { get; }
|
||||
public MediationHandle HostPresenceHandle { get; }
|
||||
public string HostPresenceCapability { get; }
|
||||
public DateTimeOffset ExpiresAt { get; internal set; }
|
||||
public int LeaseRenewAfterSeconds { get; internal set; }
|
||||
public int HostPresenceRefreshAfterSeconds { get; }
|
||||
|
||||
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
|
||||
}
|
||||
|
||||
public interface IRendezvousPublisherClient
|
||||
{
|
||||
Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||
RegisterSessionRequest request,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||
PublishedSession session,
|
||||
UpdateSessionRequest request,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousSessionBrowserClient
|
||||
{
|
||||
Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||
BrowseSessionsRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||
BrowseSessionsRequest request,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||
SessionListingId listingId,
|
||||
GameId gameId,
|
||||
EnvironmentId environmentId,
|
||||
uint protocolVersion,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousDelay
|
||||
{
|
||||
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class RendezvousClientOptions
|
||||
{
|
||||
public int MaximumSafeRetries { get; set; } = 2;
|
||||
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||
public double JitterRatio { get; set; } = 0.2;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (MaximumSafeRetries is < 0 or > 5
|
||||
|| InitialRetryDelay < TimeSpan.Zero
|
||||
|| MaximumRetryDelay < InitialRetryDelay
|
||||
|| MaximumRetryDelay > TimeSpan.FromSeconds(30)
|
||||
|| JitterRatio is < 0 or > 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(RendezvousClientOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SystemRendezvousDelay : IRendezvousDelay
|
||||
{
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
|
||||
Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
internal sealed class RendezvousHttpTransport
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly RendezvousClientOptions _options;
|
||||
private readonly IRendezvousDelay _delay;
|
||||
|
||||
internal RendezvousHttpTransport(
|
||||
HttpClient httpClient,
|
||||
RendezvousClientOptions? options,
|
||||
IRendezvousDelay? delay)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
RendezvousClientOptions suppliedOptions = options ?? new RendezvousClientOptions();
|
||||
suppliedOptions.Validate();
|
||||
_options = new RendezvousClientOptions
|
||||
{
|
||||
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
|
||||
InitialRetryDelay = suppliedOptions.InitialRetryDelay,
|
||||
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
|
||||
JitterRatio = suppliedOptions.JitterRatio,
|
||||
};
|
||||
_delay = delay ?? new SystemRendezvousDelay();
|
||||
}
|
||||
|
||||
internal async Task<RendezvousClientResult<T>> SendSafeAsync<T>(
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage request = requestFactory();
|
||||
using HttpResponseMessage response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
if (typeof(T) == typeof(bool) && response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return RendezvousClientResult.Success((T)(object)true);
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return RendezvousClientResult.Failure<T>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an oversized success response.");
|
||||
}
|
||||
|
||||
T? value;
|
||||
try
|
||||
{
|
||||
value = JsonSerializer.Deserialize<T>(payload, ContractJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
value = default;
|
||||
}
|
||||
|
||||
return value is null
|
||||
? RendezvousClientResult.Failure<T>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an invalid success response.")
|
||||
: RendezvousClientResult.Success(value);
|
||||
}
|
||||
|
||||
ApiError error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
|
||||
{
|
||||
await _delay.DelayAsync(
|
||||
GetRetryDelay(attempt, retryAfter),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
return RendezvousClientResult.Failure<T>(error.Code, error.Message, retryAfter);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
IsTransientTransportFailure(exception, cancellationToken)
|
||||
&& attempt < _options.MaximumSafeRetries)
|
||||
{
|
||||
await _delay.DelayAsync(GetRetryDelay(attempt, null), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||
{
|
||||
return RendezvousClientResult.Failure<T>(
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
"The Rendezvous service did not return a valid response.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static HttpRequestMessage JsonRequest<T>(
|
||||
HttpMethod method,
|
||||
string uri,
|
||||
T body,
|
||||
string? publisherCredential = null)
|
||||
{
|
||||
HttpRequestMessage request = new(method, uri)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(body, ContractJson.Options),
|
||||
Encoding.UTF8,
|
||||
"application/json"),
|
||||
};
|
||||
if (publisherCredential is not null)
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
RequireCredential(publisherCredential));
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
internal static string RequireCredential(string credential) =>
|
||||
!string.IsNullOrWhiteSpace(credential)
|
||||
? credential
|
||||
: throw new ArgumentException("A publisher credential is required.", nameof(credential));
|
||||
|
||||
private static async Task<ApiError> ReadErrorAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
ApiError? error = JsonSerializer.Deserialize<ApiError>(payload, ContractJson.Options);
|
||||
return error is not null && error.Code != RendezvousErrorCode.None
|
||||
? error
|
||||
: FallbackError(response.StatusCode);
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or InvalidDataException)
|
||||
{
|
||||
return FallbackError(response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadBoundedAsync(
|
||||
HttpContent content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using Stream source = await content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
using MemoryStream destination = new();
|
||||
byte[] buffer = new byte[8192];
|
||||
while (true)
|
||||
{
|
||||
int read = await source.ReadAsync(buffer.AsMemory(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return destination.ToArray();
|
||||
}
|
||||
|
||||
if (destination.Length + read > ContractLimits.BrowserResponseMaxBytes)
|
||||
{
|
||||
throw new InvalidDataException("The service response exceeded the SDK limit.");
|
||||
}
|
||||
|
||||
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan GetRetryDelay(int attempt, int? retryAfterSeconds)
|
||||
{
|
||||
TimeSpan basis = retryAfterSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(Math.Max(0, retryAfterSeconds.Value))
|
||||
: TimeSpan.FromMilliseconds(
|
||||
_options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt));
|
||||
double bounded = Math.Min(basis.TotalMilliseconds, _options.MaximumRetryDelay.TotalMilliseconds);
|
||||
if (_options.JitterRatio == 0 || bounded == 0)
|
||||
{
|
||||
return TimeSpan.FromMilliseconds(bounded);
|
||||
}
|
||||
|
||||
byte[] random = new byte[1];
|
||||
RandomNumberGenerator.Fill(random);
|
||||
double unit = random[0] / 255d;
|
||||
double multiplier = 1 - _options.JitterRatio + (2 * _options.JitterRatio * unit);
|
||||
return TimeSpan.FromMilliseconds(Math.Min(
|
||||
bounded * multiplier,
|
||||
_options.MaximumRetryDelay.TotalMilliseconds));
|
||||
}
|
||||
|
||||
private static bool IsTransient(RendezvousErrorCode code) => code is
|
||||
RendezvousErrorCode.RateLimited
|
||||
or RendezvousErrorCode.CapacityExceeded
|
||||
or RendezvousErrorCode.ServiceUnavailable;
|
||||
|
||||
private static bool IsTransientTransportFailure(
|
||||
Exception exception,
|
||||
CancellationToken callerCancellation) =>
|
||||
exception is HttpRequestException
|
||||
or IOException
|
||||
|| exception is OperationCanceledException && !callerCancellation.IsCancellationRequested;
|
||||
|
||||
private static int? GetRetryAfterSeconds(RetryConditionHeaderValue? retryAfter) =>
|
||||
retryAfter?.Delta is TimeSpan delta
|
||||
? Math.Max(0, (int)Math.Ceiling(delta.TotalSeconds))
|
||||
: null;
|
||||
|
||||
private static ApiError FallbackError(HttpStatusCode statusCode) => new()
|
||||
{
|
||||
Code = statusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => RendezvousErrorCode.InvalidRequest,
|
||||
HttpStatusCode.Unauthorized => RendezvousErrorCode.AuthenticationRequired,
|
||||
HttpStatusCode.Forbidden => RendezvousErrorCode.Forbidden,
|
||||
HttpStatusCode.NotFound => RendezvousErrorCode.NotFound,
|
||||
HttpStatusCode.Conflict => RendezvousErrorCode.Conflict,
|
||||
HttpStatusCode.Gone => RendezvousErrorCode.Expired,
|
||||
HttpStatusCode.TooManyRequests => RendezvousErrorCode.RateLimited,
|
||||
HttpStatusCode.RequestTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||
HttpStatusCode.BadGateway => RendezvousErrorCode.ServiceUnavailable,
|
||||
HttpStatusCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
|
||||
HttpStatusCode.GatewayTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||
_ => RendezvousErrorCode.InternalError,
|
||||
},
|
||||
Message = "The service returned an error without a valid Rendezvous envelope.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
||||
{
|
||||
private readonly RendezvousHttpTransport _transport;
|
||||
private readonly IRendezvousDelay _delay;
|
||||
|
||||
public RendezvousPublisherClient(
|
||||
HttpClient httpClient,
|
||||
RendezvousClientOptions? options = null,
|
||||
IRendezvousDelay? delay = null)
|
||||
{
|
||||
_delay = delay ?? new SystemRendezvousDelay();
|
||||
_transport = new(httpClient, options, _delay);
|
||||
}
|
||||
|
||||
public async Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||
RegisterSessionRequest request,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
RegisterSessionRequest body = CopyRegistration(request);
|
||||
RendezvousClientResult<RegisterSessionResponse> result = await _transport.SendSafeAsync<RegisterSessionResponse>(
|
||||
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/sessions", body, publisherCredential),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess && result.Value is not null
|
||||
? RendezvousClientResult.Success(new PublishedSession(result.Value))
|
||||
: RendezvousClientResult.Failure<PublishedSession>(
|
||||
result.Error,
|
||||
result.Message,
|
||||
result.RetryAfterSeconds);
|
||||
}
|
||||
|
||||
public async Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
RendezvousClientResult<RenewLeaseResponse> result = await _transport.SendSafeAsync<RenewLeaseResponse>(
|
||||
() => RendezvousHttpTransport.JsonRequest(
|
||||
HttpMethod.Post,
|
||||
$"v1/sessions/{session.ListingId}/renew",
|
||||
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
|
||||
publisherCredential),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (result.IsSuccess && result.Value is not null)
|
||||
{
|
||||
session.ExpiresAt = result.Value.ExpiresAt;
|
||||
session.LeaseRenewAfterSeconds = result.Value.RenewAfterSeconds;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||
PublishedSession session,
|
||||
UpdateSessionRequest request,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
UpdateSessionRequest body = new()
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
LeaseToken = session.LeaseToken,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Capacity = CopyCapacity(request.Capacity),
|
||||
Metadata = CopyMetadata(request.Metadata),
|
||||
};
|
||||
return _transport.SendSafeAsync<bool>(
|
||||
() => RendezvousHttpTransport.JsonRequest(
|
||||
HttpMethod.Put,
|
||||
$"v1/sessions/{session.ListingId}",
|
||||
body,
|
||||
publisherCredential),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
return _transport.SendSafeAsync<bool>(
|
||||
() => RendezvousHttpTransport.JsonRequest(
|
||||
HttpMethod.Delete,
|
||||
$"v1/sessions/{session.ListingId}",
|
||||
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
|
||||
publisherCredential),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public SessionLeaseMaintainer CreateLeaseMaintainer(
|
||||
PublishedSession session,
|
||||
string publisherCredential) => new(
|
||||
this,
|
||||
session ?? throw new ArgumentNullException(nameof(session)),
|
||||
RendezvousHttpTransport.RequireCredential(publisherCredential),
|
||||
_delay);
|
||||
|
||||
private static RegisterSessionRequest CopyRegistration(RegisterSessionRequest request) => new()
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
IdempotencyKey = request.IdempotencyKey,
|
||||
GameId = request.GameId,
|
||||
EnvironmentId = request.EnvironmentId,
|
||||
RegionId = request.RegionId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Visibility = request.Visibility,
|
||||
Capacity = CopyCapacity(request.Capacity),
|
||||
Metadata = CopyMetadata(request.Metadata),
|
||||
};
|
||||
|
||||
private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new()
|
||||
{
|
||||
CurrentPlayers = capacity.CurrentPlayers,
|
||||
MaximumPlayers = capacity.MaximumPlayers,
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) =>
|
||||
new(metadata, StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserClient
|
||||
{
|
||||
private readonly RendezvousHttpTransport _transport;
|
||||
|
||||
public RendezvousSessionBrowserClient(
|
||||
HttpClient httpClient,
|
||||
RendezvousClientOptions? options = null,
|
||||
IRendezvousDelay? delay = null)
|
||||
{
|
||||
_transport = new(httpClient, options, delay);
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||
BrowseSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
string query = $"v1/sessions?contractVersion={request.ContractVersion}"
|
||||
+ $"&gameId={Escape(request.GameId.Value)}"
|
||||
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
|
||||
+ $"&protocolVersion={request.ProtocolVersion}"
|
||||
+ $"&pageSize={request.PageSize}"
|
||||
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
|
||||
+ (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty)
|
||||
+ (request.Cursor is not null ? $"&cursor={Escape(request.Cursor)}" : string.Empty);
|
||||
return _transport.SendSafeAsync<BrowseSessionsResponse>(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||
BrowseSessionsRequest request,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (maximumPages is < 1 or > 1000)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maximumPages));
|
||||
}
|
||||
|
||||
List<SessionListing> items = [];
|
||||
string? cursor = request.Cursor;
|
||||
for (int page = 0; page < maximumPages; page++)
|
||||
{
|
||||
BrowseSessionsRequest pageRequest = new()
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
GameId = request.GameId,
|
||||
EnvironmentId = request.EnvironmentId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
RegionId = request.RegionId,
|
||||
PageSize = request.PageSize,
|
||||
ExcludeFull = request.ExcludeFull,
|
||||
Cursor = cursor,
|
||||
};
|
||||
RendezvousClientResult<BrowseSessionsResponse> result = await BrowseAsync(
|
||||
pageRequest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess || result.Value is null)
|
||||
{
|
||||
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||
result.Error,
|
||||
result.Message,
|
||||
result.RetryAfterSeconds);
|
||||
}
|
||||
|
||||
items.AddRange(result.Value.Items);
|
||||
cursor = result.Value.NextCursor;
|
||||
if (string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return RendezvousClientResult.Success<IReadOnlyList<SessionListing>>(items.AsReadOnly());
|
||||
}
|
||||
}
|
||||
|
||||
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||
RendezvousErrorCode.CapacityExceeded,
|
||||
$"Browsing exceeded the configured {maximumPages}-page limit.");
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||
SessionListingId listingId,
|
||||
GameId gameId,
|
||||
EnvironmentId environmentId,
|
||||
uint protocolVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string query = $"v1/sessions/{listingId}?contractVersion={ContractLimits.ContractVersion}"
|
||||
+ $"&gameId={Escape(gameId.Value)}"
|
||||
+ $"&environmentId={Escape(environmentId.Value)}"
|
||||
+ $"&protocolVersion={protocolVersion}";
|
||||
return _transport.SendSafeAsync<GetSessionResponse>(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public enum LeaseMaintenanceStopReason
|
||||
{
|
||||
Cancelled = 1,
|
||||
Disposed = 2,
|
||||
LeaseLost = 3,
|
||||
Failed = 4,
|
||||
}
|
||||
|
||||
public sealed class LeaseMaintenanceResult
|
||||
{
|
||||
internal LeaseMaintenanceResult(LeaseMaintenanceStopReason reason, RendezvousErrorCode error)
|
||||
{
|
||||
Reason = reason;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public LeaseMaintenanceStopReason Reason { get; }
|
||||
public RendezvousErrorCode Error { get; }
|
||||
}
|
||||
|
||||
public sealed class SessionLeaseMaintainer : IAsyncDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly IRendezvousPublisherClient _publisher;
|
||||
private readonly PublishedSession _session;
|
||||
private readonly string _publisherCredential;
|
||||
private readonly IRendezvousDelay _delay;
|
||||
private readonly CancellationTokenSource _disposeCancellation = new();
|
||||
private Task<LeaseMaintenanceResult>? _activeRun;
|
||||
private Task? _disposeTask;
|
||||
private bool _disposed;
|
||||
|
||||
internal SessionLeaseMaintainer(
|
||||
IRendezvousPublisherClient publisher,
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
IRendezvousDelay? delay = null)
|
||||
{
|
||||
_publisher = publisher;
|
||||
_session = session;
|
||||
_publisherCredential = publisherCredential;
|
||||
_delay = delay ?? new SystemRendezvousDelay();
|
||||
}
|
||||
|
||||
public event EventHandler? LeaseLost;
|
||||
|
||||
public Task<LeaseMaintenanceResult> RunAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SessionLeaseMaintainer));
|
||||
}
|
||||
if (_activeRun is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Lease maintenance is already running.");
|
||||
}
|
||||
|
||||
_activeRun = RunCoreAsync(cancellationToken);
|
||||
return _activeRun;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposeTask is not null)
|
||||
{
|
||||
return new(_disposeTask);
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_disposeCancellation.Cancel();
|
||||
_disposeTask = FinishDisposeAsync(_activeRun);
|
||||
return new(_disposeTask);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FinishDisposeAsync(Task<LeaseMaintenanceResult>? active)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (active is not null)
|
||||
{
|
||||
await active.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_disposeCancellation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LeaseMaintenanceResult> RunCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
_disposeCancellation.Token);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await _delay.DelayAsync(
|
||||
TimeSpan.FromSeconds(Math.Max(1, _session.LeaseRenewAfterSeconds)),
|
||||
linked.Token).ConfigureAwait(false);
|
||||
RendezvousClientResult<RenewLeaseResponse> renewed = await _publisher.RenewAsync(
|
||||
_session,
|
||||
_publisherCredential,
|
||||
linked.Token).ConfigureAwait(false);
|
||||
if (renewed.IsSuccess)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (renewed.Error is RendezvousErrorCode.NotFound
|
||||
or RendezvousErrorCode.Expired
|
||||
or RendezvousErrorCode.Forbidden
|
||||
or RendezvousErrorCode.AuthenticationRequired)
|
||||
{
|
||||
LeaseLost?.Invoke(this, EventArgs.Empty);
|
||||
return new(LeaseMaintenanceStopReason.LeaseLost, renewed.Error);
|
||||
}
|
||||
|
||||
return new(LeaseMaintenanceStopReason.Failed, renewed.Error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (linked.IsCancellationRequested)
|
||||
{
|
||||
return new(
|
||||
_disposeCancellation.IsCancellationRequested
|
||||
? LeaseMaintenanceStopReason.Disposed
|
||||
: LeaseMaintenanceStopReason.Cancelled,
|
||||
RendezvousErrorCode.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_activeRun = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,136 @@
|
||||
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) =>
|
||||
!string.IsNullOrWhiteSpace(value)
|
||||
&& IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
|
||||
|
||||
public static bool IsDisplayNameValid(string? value) =>
|
||||
!string.IsNullOrWhiteSpace(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,200 @@
|
||||
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; }
|
||||
|
||||
[JsonRequired]
|
||||
public int LeaseRenewAfterSeconds { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public int HostPresenceRefreshAfterSeconds { 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; }
|
||||
|
||||
[JsonRequired]
|
||||
public int RenewAfterSeconds { 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 bool ExcludeFull { get; set; }
|
||||
|
||||
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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed class EphemeralCursorProtector : IDisposable
|
||||
{
|
||||
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||
private bool _disposed;
|
||||
|
||||
public string Protect(string prefix, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
string content = $"{prefix}.{EncodeBytes(payload)}";
|
||||
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
|
||||
try
|
||||
{
|
||||
string cursor = $"{content}.{EncodeBytes(signature)}";
|
||||
return ContractValidation.IsCursorValid(cursor)
|
||||
? cursor
|
||||
: throw new InvalidOperationException("The protected cursor exceeds its contract limit.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(signature);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryUnprotect(string prefix, string? cursor, out byte[] payload)
|
||||
{
|
||||
payload = [];
|
||||
if (_disposed || !ContractValidation.IsCursorValid(cursor))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] segments = cursor!.Split('.');
|
||||
if (segments.Length != 3 || !string.Equals(segments[0], prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] expected = HMACSHA256.HashData(
|
||||
_key,
|
||||
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
|
||||
if (!TryDecodeBytes(segments[2], out byte[] supplied))
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(expected);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool validSignature = supplied.Length == expected.Length
|
||||
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
|
||||
CryptographicOperations.ZeroMemory(supplied);
|
||||
CryptographicOperations.ZeroMemory(expected);
|
||||
return validSignature && TryDecodeBytes(segments[1], out payload);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
CryptographicOperations.ZeroMemory(_key);
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => "[EphemeralCursorProtector: key redacted]";
|
||||
|
||||
private static string EncodeBytes(ReadOnlySpan<byte> bytes) => Convert
|
||||
.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
private static bool TryDecodeBytes(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('_', '/');
|
||||
padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" };
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(padded);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed class SessionBrowserCursorCodec : IDisposable
|
||||
{
|
||||
private const string Prefix = "rvc1";
|
||||
private readonly EphemeralCursorProtector _protector = new();
|
||||
|
||||
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
|
||||
{
|
||||
BrowserCursorPayload payload = new()
|
||||
{
|
||||
GameId = query.Scope.GameId.Value,
|
||||
EnvironmentId = query.Scope.EnvironmentId.Value,
|
||||
ProtocolVersion = query.ProtocolVersion,
|
||||
RegionId = query.RegionId?.Value,
|
||||
ExcludeFull = query.ExcludeFull,
|
||||
AfterListingId = after.ToString(),
|
||||
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||
};
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||
try
|
||||
{
|
||||
return _protector.Protect(Prefix, encoded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecode(
|
||||
string? cursor,
|
||||
TenantScope scope,
|
||||
uint protocolVersion,
|
||||
RegionId? regionId,
|
||||
bool excludeFull,
|
||||
DateTimeOffset now,
|
||||
out SessionListingId? after)
|
||||
{
|
||||
after = null;
|
||||
if (cursor is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BrowserCursorPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<BrowserCursorPayload>(
|
||||
encodedPayload,
|
||||
ContractJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
payload = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||
}
|
||||
|
||||
if (payload is null
|
||||
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||
|| !string.Equals(payload.GameId, scope.GameId.Value, StringComparison.Ordinal)
|
||||
|| !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal)
|
||||
|| payload.ProtocolVersion != protocolVersion
|
||||
|| !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal)
|
||||
|| payload.ExcludeFull != excludeFull
|
||||
|| !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
after = listingId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose() => _protector.Dispose();
|
||||
|
||||
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
|
||||
}
|
||||
|
||||
internal sealed class BrowserCursorPayload
|
||||
{
|
||||
[JsonRequired]
|
||||
public string GameId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public string EnvironmentId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public uint ProtocolVersion { get; set; }
|
||||
|
||||
public string? RegionId { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public bool ExcludeFull { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public string AfterListingId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public long ExpiresAtUnixSeconds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||
{
|
||||
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
internal sealed class SessionBrowserService(
|
||||
IEphemeralRendezvousStore store,
|
||||
SessionBrowserCursorCodec cursors,
|
||||
IWallClock clock)
|
||||
{
|
||||
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
||||
BrowseSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = Validate(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
TenantScope scope = new(request.GameId, request.EnvironmentId);
|
||||
if (!cursors.TryDecode(
|
||||
request.Cursor,
|
||||
scope,
|
||||
request.ProtocolVersion,
|
||||
request.RegionId,
|
||||
request.ExcludeFull,
|
||||
clock.UtcNow,
|
||||
out SessionListingId? after))
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
VisibleListingQuery query = new(
|
||||
scope,
|
||||
request.ProtocolVersion,
|
||||
request.RegionId,
|
||||
request.PageSize + 1,
|
||||
after,
|
||||
request.ExcludeFull);
|
||||
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
|
||||
query,
|
||||
cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||
? RendezvousErrorCode.ServiceUnavailable
|
||||
: RendezvousErrorCode.InternalError);
|
||||
}
|
||||
|
||||
List<SessionListing> items = found.Value
|
||||
.Take(request.PageSize)
|
||||
.Select(ToContract)
|
||||
.ToList();
|
||||
bool hasMore = found.Value.Count > request.PageSize;
|
||||
while (items.Count > 0)
|
||||
{
|
||||
string? nextCursor = hasMore
|
||||
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
||||
: null;
|
||||
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
|
||||
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
||||
<= ContractLimits.BrowserResponseMaxBytes)
|
||||
{
|
||||
return new(RendezvousErrorCode.None, response);
|
||||
}
|
||||
|
||||
items.RemoveAt(items.Count - 1);
|
||||
hasMore = true;
|
||||
}
|
||||
|
||||
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
|
||||
}
|
||||
|
||||
public BrowserServiceResult<GetSessionResponse> Get(
|
||||
SessionListingId listingId,
|
||||
GameId gameId,
|
||||
EnvironmentId environmentId,
|
||||
uint protocolVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (listingId.Value == Guid.Empty
|
||||
|| string.IsNullOrEmpty(gameId.Value)
|
||||
|| string.IsNullOrEmpty(environmentId.Value)
|
||||
|| protocolVersion == 0)
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
StoreResult<StoredListing> found = store.GetListing(listingId, true, cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||
? RendezvousErrorCode.ServiceUnavailable
|
||||
: RendezvousErrorCode.NotFound);
|
||||
}
|
||||
|
||||
StoredListing listing = found.Value;
|
||||
if (listing.Definition.Scope != new TenantScope(gameId, environmentId)
|
||||
|| listing.Definition.ProtocolVersion != protocolVersion)
|
||||
{
|
||||
return new(RendezvousErrorCode.NotFound);
|
||||
}
|
||||
|
||||
return new(RendezvousErrorCode.None, new GetSessionResponse
|
||||
{
|
||||
Session = ToContract(listing),
|
||||
});
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(request.GameId.Value)
|
||||
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||
|| request.ProtocolVersion == 0
|
||||
|| (request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value))
|
||||
|| !ContractValidation.IsPageSizeValid(request.PageSize)
|
||||
|| !ContractValidation.IsCursorValid(request.Cursor)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private static SessionListing ToContract(StoredListing stored) => new()
|
||||
{
|
||||
ListingId = stored.Definition.ListingId,
|
||||
GameId = stored.Definition.Scope.GameId,
|
||||
EnvironmentId = stored.Definition.Scope.EnvironmentId,
|
||||
RegionId = stored.Definition.RegionId,
|
||||
ProtocolVersion = stored.Definition.ProtocolVersion,
|
||||
BuildVersion = stored.Definition.BuildVersion,
|
||||
DisplayName = stored.Definition.DisplayName,
|
||||
Visibility = stored.Definition.Visibility,
|
||||
PublisherTrustMode = stored.Definition.TrustMode,
|
||||
Capacity = new()
|
||||
{
|
||||
CurrentPlayers = stored.Definition.CurrentPlayers,
|
||||
MaximumPlayers = stored.Definition.MaximumPlayers,
|
||||
},
|
||||
Metadata = stored.Definition.Metadata.ToDictionary(
|
||||
static item => item.Key,
|
||||
static item => item.Value,
|
||||
StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
@@ -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,415 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
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>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("RegisterSession");
|
||||
sessions.MapPost("/{listingId}/renew", RenewLease)
|
||||
.Accepts<RenewLeaseRequest>("application/json")
|
||||
.Produces<RenewLeaseResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("RenewSessionLease");
|
||||
sessions.MapPut("/{listingId}", UpdateSession)
|
||||
.Accepts<UpdateSessionRequest>("application/json")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("UpdateSession");
|
||||
sessions.MapDelete("/{listingId}", DeleteSession)
|
||||
.Accepts<DeleteSessionRequest>("application/json")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("DeleteSession");
|
||||
sessions.MapGet("/", BrowseSessions)
|
||||
.Produces<BrowseSessionsResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("BrowseSessions");
|
||||
sessions.MapGet("/{listingId}", GetSession)
|
||||
.Produces<GetSessionResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("GetSession");
|
||||
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
||||
.Produces<BrowseHostJoinAttemptsResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.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>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("CreateJoinAttempt");
|
||||
attempts.MapDelete("/{attemptId}", CancelJoinAttempt)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("CancelJoinAttempt");
|
||||
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
||||
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
||||
.Produces<ReportConnectionOutcomeResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status501NotImplemented)
|
||||
.WithName("ReportConnectionOutcome");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult RegisterSession(
|
||||
[FromBody] RegisterSessionRequest request,
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAuthenticatePublisher(
|
||||
authorizationHeader,
|
||||
credentials,
|
||||
clock,
|
||||
out AuthenticatedPrincipal? principal))
|
||||
{
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
|
||||
principal!,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult RenewLease(
|
||||
SessionListingId listingId,
|
||||
[FromBody] RenewLeaseRequest request,
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAuthenticatePublisher(
|
||||
authorizationHeader,
|
||||
credentials,
|
||||
clock,
|
||||
out AuthenticatedPrincipal? principal))
|
||||
{
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult UpdateSession(
|
||||
SessionListingId listingId,
|
||||
[FromBody] UpdateSessionRequest request,
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAuthenticatePublisher(
|
||||
authorizationHeader,
|
||||
credentials,
|
||||
clock,
|
||||
out AuthenticatedPrincipal? principal))
|
||||
{
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<bool> result = sessions.Update(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult DeleteSession(
|
||||
SessionListingId listingId,
|
||||
[FromBody] DeleteSessionRequest request,
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAuthenticatePublisher(
|
||||
authorizationHeader,
|
||||
credentials,
|
||||
clock,
|
||||
out AuthenticatedPrincipal? principal))
|
||||
{
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<bool> result = sessions.Delete(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult BrowseSessions(
|
||||
[FromQuery] int contractVersion,
|
||||
[FromQuery] string gameId,
|
||||
[FromQuery] string environmentId,
|
||||
[FromQuery] uint protocolVersion,
|
||||
[FromQuery] string? regionId,
|
||||
[FromQuery] int? pageSize,
|
||||
[FromQuery] bool? excludeFull,
|
||||
[FromQuery] string? cursor,
|
||||
[FromServices] SessionBrowserService browser,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|
||||
|| (regionId is not null && !RegionId.TryParse(regionId, out _)))
|
||||
{
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
|
||||
{
|
||||
ContractVersion = contractVersion,
|
||||
GameId = parsedGameId,
|
||||
EnvironmentId = parsedEnvironmentId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
ExcludeFull = excludeFull ?? false,
|
||||
Cursor = cursor,
|
||||
}, cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult GetSession(
|
||||
SessionListingId listingId,
|
||||
[FromQuery] int contractVersion,
|
||||
[FromQuery] string gameId,
|
||||
[FromQuery] string environmentId,
|
||||
[FromQuery] uint protocolVersion,
|
||||
[FromServices] SessionBrowserService browser,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
|
||||
{
|
||||
return Error(RendezvousErrorCode.UnsupportedContractVersion);
|
||||
}
|
||||
|
||||
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId))
|
||||
{
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
BrowserServiceResult<GetSessionResponse> result = browser.Get(
|
||||
listingId,
|
||||
parsedGameId,
|
||||
parsedEnvironmentId,
|
||||
protocolVersion,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult BrowseHostJoinAttempts(
|
||||
SessionListingId listingId,
|
||||
[FromQuery] int contractVersion,
|
||||
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
|
||||
[FromQuery] int? pageSize,
|
||||
[FromQuery] string? cursor,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
|
||||
listingId,
|
||||
contractVersion,
|
||||
leaseToken,
|
||||
pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
cursor,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult CreateJoinAttempt(
|
||||
[FromBody] CreateJoinAttemptRequest request,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress)
|
||||
{
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
|
||||
clientSubject,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult CancelJoinAttempt(
|
||||
JoinAttemptId attemptId,
|
||||
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JoinAttemptServiceResult<bool> result = attempts.Cancel(
|
||||
attemptId,
|
||||
clientPunchCapability,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
private static bool TryAuthenticatePublisher(
|
||||
string? authorizationHeader,
|
||||
PrincipalCredentialService credentials,
|
||||
IWallClock clock,
|
||||
out AuthenticatedPrincipal? principal)
|
||||
{
|
||||
principal = null;
|
||||
const string bearerPrefix = "Bearer ";
|
||||
if (authorizationHeader is null
|
||||
|| !authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string token = authorizationHeader[bearerPrefix.Length..];
|
||||
CredentialValidationResult validation = credentials.Validate(token, clock.UtcNow);
|
||||
if (!validation.IsValid || validation.Principal is not IPublisherPrincipal)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
principal = validation.Principal;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult Error(RendezvousErrorCode code) => Results.Json(
|
||||
new ApiError
|
||||
{
|
||||
Code = code,
|
||||
Message = ErrorMessage(code),
|
||||
},
|
||||
ContractJson.Options,
|
||||
statusCode: ErrorStatus(code));
|
||||
|
||||
private static IResult AuthenticationRequired(HttpContext context)
|
||||
{
|
||||
context.Response.Headers.WWWAuthenticate = "Bearer";
|
||||
return Error(RendezvousErrorCode.AuthenticationRequired);
|
||||
}
|
||||
|
||||
private static int ErrorStatus(RendezvousErrorCode code) => code switch
|
||||
{
|
||||
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
|
||||
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
|
||||
RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound,
|
||||
RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
|
||||
RendezvousErrorCode.Expired => StatusCodes.Status410Gone,
|
||||
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
|
||||
RendezvousErrorCode.InternalError => StatusCodes.Status500InternalServerError,
|
||||
_ => StatusCodes.Status400BadRequest,
|
||||
};
|
||||
|
||||
private static string ErrorMessage(RendezvousErrorCode code) => code switch
|
||||
{
|
||||
RendezvousErrorCode.AuthenticationRequired => "A valid publisher bearer credential is required.",
|
||||
RendezvousErrorCode.Forbidden => "The publisher is not authorized for this operation.",
|
||||
RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.",
|
||||
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
|
||||
RendezvousErrorCode.Expired => "The session lease has expired.",
|
||||
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
|
||||
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
|
||||
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
|
||||
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
|
||||
_ => "The session request is invalid.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Http;
|
||||
|
||||
internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (httpContext.Response.HasStarted)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool invalidRequest = exception is BadHttpRequestException or JsonException;
|
||||
httpContext.Response.StatusCode = invalidRequest
|
||||
? StatusCodes.Status400BadRequest
|
||||
: StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsJsonAsync(
|
||||
new ApiError
|
||||
{
|
||||
Code = invalidRequest
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.InternalError,
|
||||
Message = invalidRequest
|
||||
? "The request body, route, or query value is invalid."
|
||||
: "The service could not complete the request.",
|
||||
},
|
||||
ContractJson.Options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
|
||||
internal sealed class JoinAttemptCursorCodec : IDisposable
|
||||
{
|
||||
private const string Prefix = "rvj1";
|
||||
private readonly EphemeralCursorProtector _protector = new();
|
||||
|
||||
public string Encode(
|
||||
SessionListingId listingId,
|
||||
JoinAttemptId after,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
JoinAttemptCursorPayload payload = new()
|
||||
{
|
||||
ListingId = listingId.ToString(),
|
||||
AfterAttemptId = after.ToString(),
|
||||
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||
};
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||
try
|
||||
{
|
||||
return _protector.Protect(Prefix, encoded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecode(
|
||||
string? cursor,
|
||||
SessionListingId listingId,
|
||||
DateTimeOffset now,
|
||||
out JoinAttemptId? after)
|
||||
{
|
||||
after = null;
|
||||
if (cursor is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
JoinAttemptCursorPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<JoinAttemptCursorPayload>(
|
||||
encodedPayload,
|
||||
ContractJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
payload = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||
}
|
||||
|
||||
if (payload is null
|
||||
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||
|| !string.Equals(payload.ListingId, listingId.ToString(), StringComparison.Ordinal)
|
||||
|| !JoinAttemptId.TryParse(payload.AfterAttemptId, out JoinAttemptId attemptId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
after = attemptId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose() => _protector.Dispose();
|
||||
|
||||
public override string ToString() => "[JoinAttemptCursorCodec: key and cursors redacted]";
|
||||
}
|
||||
|
||||
internal sealed class JoinAttemptCursorPayload
|
||||
{
|
||||
[JsonRequired]
|
||||
public string ListingId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public string AfterAttemptId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public long ExpiresAtUnixSeconds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
|
||||
internal sealed record JoinAttemptServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||
{
|
||||
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
internal sealed record ConnectionTicketGrant(string Ticket, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public override string ToString() => "[ConnectionTicketGrant: ticket redacted]";
|
||||
}
|
||||
|
||||
internal sealed class JoinAttemptService(
|
||||
GamePolicyRegistry policies,
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities,
|
||||
JoinAttemptCursorCodec cursors,
|
||||
IWallClock clock)
|
||||
{
|
||||
public string CreateAnonymousClientSubject(IPAddress remoteAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remoteAddress);
|
||||
IPAddress normalized = remoteAddress.IsIPv4MappedToIPv6
|
||||
? remoteAddress.MapToIPv4()
|
||||
: remoteAddress;
|
||||
return capabilities.DeriveOpaqueIdentifier("join-http-client", normalized.ToString());
|
||||
}
|
||||
|
||||
public JoinAttemptServiceResult<CreateJoinAttemptResponse> Create(
|
||||
string clientSubject,
|
||||
CreateJoinAttemptRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (string.IsNullOrWhiteSpace(clientSubject))
|
||||
{
|
||||
throw new ArgumentException("A bounded client subject is required.", nameof(clientSubject));
|
||||
}
|
||||
|
||||
RendezvousErrorCode validation = ValidateCreate(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
if (!policies.TryGet(request.GameId, request.EnvironmentId, out GamePolicy? policy)
|
||||
|| policy is null)
|
||||
{
|
||||
return new(RendezvousErrorCode.NotFound);
|
||||
}
|
||||
|
||||
if (!policy.AllowsProtocol(request.ProtocolVersion))
|
||||
{
|
||||
return new(RendezvousErrorCode.IncompatibleProtocol);
|
||||
}
|
||||
|
||||
string requestFingerprint = ComputeRequestFingerprint(request);
|
||||
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt);
|
||||
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|
||||
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|
||||
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|
||||
|| !capabilities.TryFingerprint(connectionTicket, out SecretFingerprint ticketFingerprint))
|
||||
{
|
||||
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
|
||||
}
|
||||
|
||||
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
|
||||
"join-attempt-id",
|
||||
clientSubject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
|
||||
"join-mediation-handle",
|
||||
clientSubject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
StoreResult<StoredJoinAttempt> created = store.CreateJoinAttempt(new()
|
||||
{
|
||||
IdempotencyOwner = clientSubject,
|
||||
IdempotencyKey = request.IdempotencyKey,
|
||||
RequestFingerprint = requestFingerprint,
|
||||
ClientSubject = clientSubject,
|
||||
AttemptId = attemptId,
|
||||
MediationHandle = mediationHandle,
|
||||
Scope = new(request.GameId, request.EnvironmentId),
|
||||
ListingId = request.ListingId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
HostCapabilityFingerprint = hostFingerprint,
|
||||
ClientCapabilityFingerprint = clientFingerprint,
|
||||
ConnectionTicketFingerprint = ticketFingerprint,
|
||||
CapabilityDerivationSalt = derivationSalt,
|
||||
ScopeAttemptLimit = policy.MaxActiveJoinAttempts,
|
||||
}, cancellationToken);
|
||||
if (!created.Succeeded || created.Value is null)
|
||||
{
|
||||
return new(created.Code.ToContractError());
|
||||
}
|
||||
|
||||
StoredJoinAttempt persisted = created.Value;
|
||||
clientCapability = Derive(
|
||||
"join-client-punch",
|
||||
persisted.ClientSubject,
|
||||
persisted.IdempotencyKey,
|
||||
persisted.RequestFingerprint,
|
||||
persisted.CapabilityDerivationSalt);
|
||||
if (!capabilities.TryFingerprint(clientCapability, out SecretFingerprint persistedFingerprint)
|
||||
|| persistedFingerprint != persisted.ClientCapabilityFingerprint)
|
||||
{
|
||||
throw new InvalidOperationException("Stored join state could not reproduce its client capability.");
|
||||
}
|
||||
return new(RendezvousErrorCode.None, new CreateJoinAttemptResponse
|
||||
{
|
||||
AttemptId = persisted.AttemptId,
|
||||
MediationHandle = persisted.MediationHandle,
|
||||
ClientPunchCapability = clientCapability,
|
||||
ExpiresAt = persisted.ExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
public JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> BrowseForHost(
|
||||
SessionListingId listingId,
|
||||
int contractVersion,
|
||||
string? leaseToken,
|
||||
int pageSize,
|
||||
string? cursor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(version);
|
||||
}
|
||||
|
||||
if (!ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||
|| !ContractValidation.IsPageSizeValid(pageSize)
|
||||
|| !ContractValidation.IsCursorValid(cursor)
|
||||
|| !capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint))
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
if (!cursors.TryDecode(cursor, listingId, clock.UtcNow, out JoinAttemptId? after))
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
StoreResult<IReadOnlyList<StoredJoinAttempt>> found = store.BrowseHostJoinAttempts(new(
|
||||
listingId,
|
||||
leaseFingerprint,
|
||||
pageSize + 1,
|
||||
after), cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return new(found.Code.ToContractError());
|
||||
}
|
||||
|
||||
bool hasMore = found.Value.Count > pageSize;
|
||||
StoredJoinAttempt[] page = found.Value.Take(pageSize).ToArray();
|
||||
BrowseHostJoinAttemptsResponse response = new()
|
||||
{
|
||||
Items = page.Select(CreateHostAttempt).ToList(),
|
||||
NextCursor = hasMore && page.Length > 0
|
||||
? cursors.Encode(listingId, page[^1].AttemptId, clock.UtcNow)
|
||||
: null,
|
||||
};
|
||||
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||
return ContractValidation.IsBrowserResponseSizeValid(encodedBytes)
|
||||
? new(RendezvousErrorCode.None, response)
|
||||
: new(RendezvousErrorCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
public JoinAttemptServiceResult<bool> Cancel(
|
||||
JoinAttemptId attemptId,
|
||||
string? clientPunchCapability,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!ContractValidation.IsCapabilityValid(clientPunchCapability)
|
||||
|| !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
StoreResult<bool> cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken);
|
||||
return cancelled.Succeeded
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(cancelled.Code.ToContractError());
|
||||
}
|
||||
|
||||
public JoinAttemptServiceResult<ConnectionTicketGrant> IssueConnectionTicket(
|
||||
StoredJoinAttempt attempt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attempt);
|
||||
if (!attempt.IntroductionConsumed)
|
||||
{
|
||||
return new(RendezvousErrorCode.Conflict);
|
||||
}
|
||||
|
||||
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
|
||||
{
|
||||
return new(RendezvousErrorCode.Expired);
|
||||
}
|
||||
|
||||
string ticket = Derive(
|
||||
"connection-ticket",
|
||||
attempt.ClientSubject,
|
||||
attempt.IdempotencyKey,
|
||||
attempt.RequestFingerprint,
|
||||
attempt.CapabilityDerivationSalt);
|
||||
if (!ContractValidation.IsConnectionTicketValid(ticket)
|
||||
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|
||||
|| fingerprint != attempt.ConnectionTicketFingerprint)
|
||||
{
|
||||
throw new InvalidOperationException("Stored join state could not reproduce its connection ticket.");
|
||||
}
|
||||
|
||||
return new(RendezvousErrorCode.None, new(ticket, attempt.ConnectionTicketExpiresAt));
|
||||
}
|
||||
|
||||
private HostJoinAttempt CreateHostAttempt(StoredJoinAttempt attempt)
|
||||
{
|
||||
string capability = Derive(
|
||||
"join-host-punch",
|
||||
attempt.ClientSubject,
|
||||
attempt.IdempotencyKey,
|
||||
attempt.RequestFingerprint,
|
||||
attempt.CapabilityDerivationSalt);
|
||||
if (!ContractValidation.IsCapabilityValid(capability)
|
||||
|| !capabilities.TryFingerprint(capability, out SecretFingerprint fingerprint)
|
||||
|| fingerprint != attempt.HostCapabilityFingerprint)
|
||||
{
|
||||
throw new InvalidOperationException("Stored join state could not reproduce its host capability.");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
AttemptId = attempt.AttemptId,
|
||||
MediationHandle = attempt.MediationHandle,
|
||||
HostPunchCapability = capability,
|
||||
ExpiresAt = attempt.ExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||
|| request.ListingId.Value == Guid.Empty
|
||||
|| request.ProtocolVersion == 0
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private static string ComputeRequestFingerprint(CreateJoinAttemptRequest request)
|
||||
{
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(request, ContractJson.Options);
|
||||
byte[] digest = SHA256.HashData(encoded);
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
try
|
||||
{
|
||||
return Encode(digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
private string Derive(
|
||||
string purpose,
|
||||
string clientSubject,
|
||||
CreateJoinAttemptRequest request,
|
||||
string requestFingerprint,
|
||||
string derivationSalt) => Derive(
|
||||
purpose,
|
||||
clientSubject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
|
||||
private string Derive(
|
||||
string purpose,
|
||||
string clientSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt) => capabilities.DeriveCapability(
|
||||
purpose,
|
||||
clientSubject,
|
||||
idempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
|
||||
private static bool CredentialLengthsAreValid(
|
||||
string hostCapability,
|
||||
string clientCapability,
|
||||
string ticket) =>
|
||||
ContractValidation.IsCapabilityValid(hostCapability)
|
||||
&& ContractValidation.IsCapabilityValid(clientCapability)
|
||||
&& ContractValidation.IsConnectionTicketValid(ticket)
|
||||
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
|
||||
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
|
||||
|
||||
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||
.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
@@ -1,7 +1,132 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
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;
|
||||
});
|
||||
options.AddDocumentTransformer(static (document, context, cancellationToken) =>
|
||||
{
|
||||
const string schemeName = "PublisherBearer";
|
||||
document.Components ??= new OpenApiComponents();
|
||||
document.Components.SecuritySchemes ??=
|
||||
new Dictionary<string, IOpenApiSecurityScheme>(StringComparer.Ordinal);
|
||||
document.Components.SecuritySchemes[schemeName] = new OpenApiSecurityScheme
|
||||
{
|
||||
Type = SecuritySchemeType.Http,
|
||||
Scheme = "bearer",
|
||||
BearerFormat = "rv1 publisher credential",
|
||||
Description = "Tenant-scoped publisher credential issued during game provisioning.",
|
||||
};
|
||||
|
||||
HashSet<string> securedOperations = new(StringComparer.Ordinal)
|
||||
{
|
||||
"RegisterSession",
|
||||
"RenewSessionLease",
|
||||
"UpdateSession",
|
||||
"DeleteSession",
|
||||
};
|
||||
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
|
||||
foreach (OpenApiPathItem path in document.Paths.Values)
|
||||
{
|
||||
if (path.Operations is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
||||
operation => securedOperations.Contains(operation.OperationId ?? string.Empty)))
|
||||
{
|
||||
operation.Security ??= [];
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[reference] = [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
});
|
||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||
ContractJson.Configure(options.SerializerOptions));
|
||||
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
|
||||
SystemRendezvousClock rendezvousClock = new();
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore stateStore = new(
|
||||
stateOptions,
|
||||
rendezvousClock,
|
||||
rendezvousClock);
|
||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
||||
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
||||
|
||||
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);
|
||||
EphemeralCapabilityIssuer sessionCapabilities = new();
|
||||
builder.Services.AddSingleton(sessionCapabilities);
|
||||
builder.Services.AddSingleton<ISessionCapabilityService>(sessionCapabilities);
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||
builder.Services.AddSingleton<JoinAttemptService>();
|
||||
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||
}
|
||||
|
||||
builder.Services
|
||||
.AddOptions<UdpMediatorOptions>()
|
||||
@@ -12,16 +137,40 @@ 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.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
||||
|
||||
app.MapGet("/health/live", static () => Results.Ok(new { status = "live" }));
|
||||
app.UseExceptionHandler();
|
||||
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,
|
||||
IEphemeralRendezvousStore state) =>
|
||||
mediator.LocalEndpoint is null
|
||||
|| !provisioning.IsReady
|
||||
|| !state.IsAvailable
|
||||
|| state.IsDraining
|
||||
? 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,458 @@
|
||||
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 1–128 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.SignatureInvalid);
|
||||
}
|
||||
|
||||
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);
|
||||
if (string.Equals(Encode(bytes), value, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
bytes = [];
|
||||
return false;
|
||||
}
|
||||
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 1–64 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,172 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Sessions;
|
||||
|
||||
internal interface ISessionCapabilityService
|
||||
{
|
||||
string CreateDerivationSalt();
|
||||
string DeriveCapability(
|
||||
string purpose,
|
||||
string ownerSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt);
|
||||
Guid DeriveGuid(
|
||||
string purpose,
|
||||
string ownerSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt);
|
||||
string DeriveOpaqueIdentifier(string purpose, string value);
|
||||
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
|
||||
}
|
||||
|
||||
internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDisposable
|
||||
{
|
||||
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||
private bool _disposed;
|
||||
|
||||
public string CreateDerivationSalt()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(32);
|
||||
try
|
||||
{
|
||||
return Encode(salt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(salt);
|
||||
}
|
||||
}
|
||||
|
||||
public string DeriveCapability(
|
||||
string purpose,
|
||||
string ownerSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt)
|
||||
{
|
||||
byte[] digest = Derive(
|
||||
purpose,
|
||||
ownerSubject,
|
||||
idempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
try
|
||||
{
|
||||
return Encode(digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
public Guid DeriveGuid(
|
||||
string purpose,
|
||||
string ownerSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt)
|
||||
{
|
||||
byte[] digest = Derive(
|
||||
purpose,
|
||||
ownerSubject,
|
||||
idempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
try
|
||||
{
|
||||
Span<byte> guidBytes = digest.AsSpan(0, 16);
|
||||
guidBytes[7] = (byte)((guidBytes[7] & 0x0f) | 0x80);
|
||||
guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80);
|
||||
return new Guid(guidBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
public string DeriveOpaqueIdentifier(string purpose, string value)
|
||||
{
|
||||
byte[] digest = Derive(purpose, value);
|
||||
try
|
||||
{
|
||||
return Encode(digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
|
||||
{
|
||||
fingerprint = default;
|
||||
if (_disposed
|
||||
|| capability is null
|
||||
|| capability.Length != 43
|
||||
|| capability.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;
|
||||
}
|
||||
|
||||
byte[] digest = Derive("fingerprint", capability);
|
||||
try
|
||||
{
|
||||
fingerprint = new SecretFingerprint(Encode(digest));
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
CryptographicOperations.ZeroMemory(_key);
|
||||
}
|
||||
|
||||
public override string ToString() => "[EphemeralCapabilityIssuer: key and capabilities redacted]";
|
||||
|
||||
private byte[] Derive(params string[] segments)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
using IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, _key);
|
||||
Span<byte> length = stackalloc byte[sizeof(int)];
|
||||
foreach (string segment in segments)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(segment);
|
||||
byte[] encoded = Encoding.UTF8.GetBytes(segment);
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, encoded.Length);
|
||||
hmac.AppendData(length);
|
||||
hmac.AppendData(encoded);
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
}
|
||||
|
||||
return hmac.GetHashAndReset();
|
||||
}
|
||||
|
||||
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||
.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Sessions;
|
||||
|
||||
internal sealed record SessionLeaseTiming(
|
||||
int LeaseRenewAfterSeconds,
|
||||
int HostPresenceRefreshAfterSeconds)
|
||||
{
|
||||
public static SessionLeaseTiming From(EphemeralStoreOptions options) => new(
|
||||
Math.Max(1, (int)(options.LeaseLifetime.TotalSeconds / 2)),
|
||||
Math.Max(1, (int)(options.PresenceLifetime.TotalSeconds / 2)));
|
||||
}
|
||||
|
||||
internal sealed record SessionServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||
{
|
||||
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
internal sealed class SessionLeaseService(
|
||||
PublisherAuthorizationService authorization,
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities,
|
||||
SessionLeaseTiming timing,
|
||||
IWallClock clock)
|
||||
{
|
||||
public SessionServiceResult<RegisterSessionResponse> Register(
|
||||
AuthenticatedPrincipal principal,
|
||||
RegisterSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = ValidateRegistration(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
PublisherAuthorizationResult authorized = authorization.Authorize(
|
||||
principal,
|
||||
request.GameId,
|
||||
request.EnvironmentId,
|
||||
request.RegionId,
|
||||
request.ProtocolVersion,
|
||||
request.Visibility,
|
||||
request.Metadata,
|
||||
clock.UtcNow);
|
||||
if (!authorized.IsAllowed || authorized.Context is null)
|
||||
{
|
||||
return new(MapAuthorization(authorized.Error));
|
||||
}
|
||||
|
||||
AuthorizedPublisherContext context = authorized.Context;
|
||||
string requestFingerprint = ComputeRegistrationFingerprint(request);
|
||||
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||
string leaseToken = capabilities.DeriveCapability(
|
||||
"lease-token",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
string presenceCapability = capabilities.DeriveCapability(
|
||||
"host-presence",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint)
|
||||
|| !capabilities.TryFingerprint(presenceCapability, out SecretFingerprint presenceFingerprint))
|
||||
{
|
||||
throw new InvalidOperationException("Derived session capabilities could not be fingerprinted.");
|
||||
}
|
||||
|
||||
SessionListingId listingId = new(capabilities.DeriveGuid(
|
||||
"listing-id",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
LeaseId leaseId = new(capabilities.DeriveGuid(
|
||||
"lease-id",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
MediationHandle presenceHandle = new(capabilities.DeriveGuid(
|
||||
"presence-handle",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
int ownerLimit = context.TrustMode == PublisherTrustMode.AnonymousUnlisted
|
||||
? context.Policy.MaxAnonymousListingsPerAddress
|
||||
: context.Policy.MaxListingsPerPrincipal;
|
||||
if (ownerLimit <= 0)
|
||||
{
|
||||
return new(RendezvousErrorCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
StoreResult<StoredListing> created = store.CreateListing(new(
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
new ListingDefinition
|
||||
{
|
||||
ListingId = listingId,
|
||||
LeaseId = leaseId,
|
||||
Scope = new(context.GameId, context.EnvironmentId),
|
||||
OwnerSubject = context.Subject,
|
||||
RegionId = context.RegionId,
|
||||
ProtocolVersion = context.ProtocolVersion,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Visibility = context.Visibility,
|
||||
TrustMode = context.TrustMode,
|
||||
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||
Metadata = request.Metadata,
|
||||
LeaseFingerprint = leaseFingerprint,
|
||||
HostPresenceHandle = presenceHandle,
|
||||
HostPresenceFingerprint = presenceFingerprint,
|
||||
CapabilityDerivationSalt = derivationSalt,
|
||||
},
|
||||
ownerLimit), cancellationToken);
|
||||
if (!created.Succeeded || created.Value is null)
|
||||
{
|
||||
return new(created.Code.ToContractError());
|
||||
}
|
||||
|
||||
ListingDefinition persisted = created.Value.Definition;
|
||||
leaseToken = capabilities.DeriveCapability(
|
||||
"lease-token",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
persisted.CapabilityDerivationSalt);
|
||||
presenceCapability = capabilities.DeriveCapability(
|
||||
"host-presence",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
persisted.CapabilityDerivationSalt);
|
||||
|
||||
return new(RendezvousErrorCode.None, new RegisterSessionResponse
|
||||
{
|
||||
ListingId = persisted.ListingId,
|
||||
LeaseId = persisted.LeaseId,
|
||||
LeaseToken = leaseToken,
|
||||
HostPresenceHandle = persisted.HostPresenceHandle,
|
||||
HostPresenceCapability = presenceCapability,
|
||||
ExpiresAt = created.Value.LeaseExpiresAt,
|
||||
LeaseRenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||
HostPresenceRefreshAfterSeconds = timing.HostPresenceRefreshAfterSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
public SessionServiceResult<RenewLeaseResponse> Renew(
|
||||
AuthenticatedPrincipal principal,
|
||||
SessionListingId listingId,
|
||||
RenewLeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||
principal,
|
||||
listingId,
|
||||
request.LeaseToken,
|
||||
cancellationToken,
|
||||
out StoredListing? listing);
|
||||
if (lookup != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(lookup);
|
||||
}
|
||||
|
||||
StoredListing ownedListing = listing!;
|
||||
PublisherAuthorizationResult authorized = AuthorizeExisting(
|
||||
principal,
|
||||
ownedListing,
|
||||
ownedListing.Definition.Metadata);
|
||||
if (!authorized.IsAllowed)
|
||||
{
|
||||
return new(MapAuthorization(authorized.Error));
|
||||
}
|
||||
|
||||
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||
StoreResult<StoredListing> renewed = store.RenewLease(new(
|
||||
listingId,
|
||||
ownedListing.Definition.LeaseId,
|
||||
fingerprint,
|
||||
ownedListing.Definition.OwnerSubject,
|
||||
ownedListing.Version), cancellationToken);
|
||||
return renewed.Succeeded && renewed.Value is not null
|
||||
? new(RendezvousErrorCode.None, new RenewLeaseResponse
|
||||
{
|
||||
ExpiresAt = renewed.Value.LeaseExpiresAt,
|
||||
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||
})
|
||||
: new(renewed.Code.ToContractError());
|
||||
}
|
||||
|
||||
public SessionServiceResult<bool> Update(
|
||||
AuthenticatedPrincipal principal,
|
||||
SessionListingId listingId,
|
||||
UpdateSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = ValidateUpdate(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||
principal,
|
||||
listingId,
|
||||
request.LeaseToken,
|
||||
cancellationToken,
|
||||
out StoredListing? listing);
|
||||
if (lookup != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(lookup);
|
||||
}
|
||||
|
||||
StoredListing ownedListing = listing!;
|
||||
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
|
||||
if (!authorized.IsAllowed)
|
||||
{
|
||||
return new(MapAuthorization(authorized.Error));
|
||||
}
|
||||
|
||||
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||
StoreResult<StoredListing> updated = store.UpdateListing(new(
|
||||
listingId,
|
||||
ownedListing.Definition.LeaseId,
|
||||
fingerprint,
|
||||
ownedListing.Definition.OwnerSubject,
|
||||
request.BuildVersion,
|
||||
request.DisplayName,
|
||||
request.Capacity.CurrentPlayers,
|
||||
request.Capacity.MaximumPlayers,
|
||||
request.Metadata), cancellationToken);
|
||||
return updated.Succeeded
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(updated.Code.ToContractError());
|
||||
}
|
||||
|
||||
public SessionServiceResult<bool> Delete(
|
||||
AuthenticatedPrincipal principal,
|
||||
SessionListingId listingId,
|
||||
DeleteSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
if (principal is not IPublisherPrincipal publisher
|
||||
|| !capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return new(RendezvousErrorCode.Forbidden);
|
||||
}
|
||||
|
||||
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return found.Code == StoreResultCode.ServiceUnavailable
|
||||
? new(RendezvousErrorCode.ServiceUnavailable)
|
||||
: new(RendezvousErrorCode.None, true);
|
||||
}
|
||||
|
||||
StoreResult<bool> deleted = store.DeleteListing(new(
|
||||
listingId,
|
||||
found.Value.Definition.LeaseId,
|
||||
fingerprint,
|
||||
publisher.Subject), cancellationToken);
|
||||
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(deleted.Code.ToContractError());
|
||||
}
|
||||
|
||||
private RendezvousErrorCode GetAuthorizedListing(
|
||||
AuthenticatedPrincipal principal,
|
||||
SessionListingId listingId,
|
||||
string leaseToken,
|
||||
CancellationToken cancellationToken,
|
||||
out StoredListing? listing)
|
||||
{
|
||||
listing = null;
|
||||
if (principal is not IPublisherPrincipal publisher)
|
||||
{
|
||||
return RendezvousErrorCode.Forbidden;
|
||||
}
|
||||
|
||||
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return RendezvousErrorCode.NotFound;
|
||||
}
|
||||
|
||||
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return found.Code.ToContractError();
|
||||
}
|
||||
|
||||
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|
||||
|| found.Value.Definition.LeaseFingerprint != fingerprint)
|
||||
{
|
||||
return RendezvousErrorCode.NotFound;
|
||||
}
|
||||
|
||||
listing = found.Value;
|
||||
return RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private PublisherAuthorizationResult AuthorizeExisting(
|
||||
AuthenticatedPrincipal principal,
|
||||
StoredListing listing,
|
||||
IReadOnlyDictionary<string, string> metadata) => authorization.Authorize(
|
||||
principal,
|
||||
listing.Definition.Scope.GameId,
|
||||
listing.Definition.Scope.EnvironmentId,
|
||||
listing.Definition.RegionId,
|
||||
listing.Definition.ProtocolVersion,
|
||||
listing.Definition.Visibility,
|
||||
metadata,
|
||||
clock.UtcNow);
|
||||
|
||||
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||
|| string.IsNullOrEmpty(request.RegionId.Value)
|
||||
|| request.ProtocolVersion == 0
|
||||
|| !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|
||||
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||
|| !Enum.IsDefined(request.Visibility)
|
||||
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode ValidateUpdate(UpdateSessionRequest request)
|
||||
{
|
||||
RendezvousErrorCode lease = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||
if (lease != RendezvousErrorCode.None)
|
||||
{
|
||||
return lease;
|
||||
}
|
||||
|
||||
return !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|
||||
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode ValidateLeaseRequest(int contractVersion, string leaseToken)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||
? RendezvousErrorCode.None
|
||||
: RendezvousErrorCode.InvalidRequest;
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode MapAuthorization(PublisherAuthorizationError error) => error switch
|
||||
{
|
||||
PublisherAuthorizationError.PrincipalExpired => RendezvousErrorCode.AuthenticationRequired,
|
||||
PublisherAuthorizationError.ProtocolNotAllowed => RendezvousErrorCode.IncompatibleProtocol,
|
||||
PublisherAuthorizationError.RegionNotAllowed
|
||||
or PublisherAuthorizationError.VisibilityNotAllowed
|
||||
or PublisherAuthorizationError.AnonymousMustBeUnlisted
|
||||
or PublisherAuthorizationError.MetadataNotAllowed => RendezvousErrorCode.InvalidRequest,
|
||||
_ => RendezvousErrorCode.Forbidden,
|
||||
};
|
||||
|
||||
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
|
||||
{
|
||||
RegisterSessionRequest canonical = new()
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
IdempotencyKey = request.IdempotencyKey,
|
||||
GameId = request.GameId,
|
||||
EnvironmentId = request.EnvironmentId,
|
||||
RegionId = request.RegionId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Visibility = request.Visibility,
|
||||
Capacity = new SessionCapacity
|
||||
{
|
||||
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||
},
|
||||
Metadata = request.Metadata
|
||||
.OrderBy(static item => item.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
|
||||
};
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
|
||||
byte[] digest = SHA256.HashData(encoded);
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
try
|
||||
{
|
||||
return Convert.ToBase64String(digest).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
internal interface IWallClock
|
||||
{
|
||||
DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
|
||||
internal interface IMonotonicClock
|
||||
{
|
||||
TimeSpan Elapsed { get; }
|
||||
}
|
||||
|
||||
internal sealed class SystemRendezvousClock : IWallClock, IMonotonicClock
|
||||
{
|
||||
private readonly long _origin = Stopwatch.GetTimestamp();
|
||||
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
|
||||
public TimeSpan Elapsed => Stopwatch.GetElapsedTime(_origin);
|
||||
}
|
||||
|
||||
internal sealed record EphemeralStoreOptions
|
||||
{
|
||||
public int MaxListings { get; init; } = 25_000;
|
||||
public int MaxPresenceBindings { get; init; } = 25_000;
|
||||
public int MaxJoinAttempts { get; init; } = 10_000;
|
||||
public int MaxReplayEntries { get; init; } = 30_000;
|
||||
public int MaxRevocations { get; init; } = 10_000;
|
||||
public int MaxIdempotencyEntries { get; init; } = 35_000;
|
||||
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
|
||||
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan ConnectionTicketLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||
public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
|
||||
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
RequirePositive(MaxListings, nameof(MaxListings));
|
||||
RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings));
|
||||
RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts));
|
||||
RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries));
|
||||
RequirePositive(MaxRevocations, nameof(MaxRevocations));
|
||||
RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries));
|
||||
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
|
||||
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
|
||||
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
|
||||
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
|
||||
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
||||
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
||||
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
||||
if (ConnectionTicketLifetime > JoinAttemptLifetime)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(ConnectionTicketLifetime),
|
||||
"Connection tickets cannot outlive their join attempt.");
|
||||
}
|
||||
|
||||
if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(IdempotencyLifetime),
|
||||
"Idempotency retention must cover every idempotent resource lifetime.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequirePositive(int value, string name)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(name, "Store capacity must be positive.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireDuration(TimeSpan value, TimeSpan maximum, string name)
|
||||
{
|
||||
if (value <= TimeSpan.Zero || value > maximum)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(name, $"Duration must be positive and no greater than {maximum}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct TenantScope(GameId GameId, EnvironmentId EnvironmentId);
|
||||
|
||||
internal readonly struct SecretFingerprint : IEquatable<SecretFingerprint>
|
||||
{
|
||||
private readonly string? _value;
|
||||
|
||||
public SecretFingerprint(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
|
||||
{
|
||||
throw new ArgumentException("Secret fingerprints must contain 1-128 characters.", nameof(value));
|
||||
}
|
||||
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(_value) && _value.Length <= 128;
|
||||
public bool Equals(SecretFingerprint other)
|
||||
{
|
||||
ReadOnlySpan<char> left = _value.AsSpan();
|
||||
ReadOnlySpan<char> right = other._value.AsSpan();
|
||||
if (left.Length != right.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int difference = 0;
|
||||
for (int index = 0; index < left.Length; index++)
|
||||
{
|
||||
difference |= left[index] ^ right[index];
|
||||
}
|
||||
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => obj is SecretFingerprint other && Equals(other);
|
||||
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(_value ?? string.Empty);
|
||||
public override string ToString() => "[REDACTED]";
|
||||
public static bool operator ==(SecretFingerprint left, SecretFingerprint right) => left.Equals(right);
|
||||
public static bool operator !=(SecretFingerprint left, SecretFingerprint right) => !left.Equals(right);
|
||||
}
|
||||
|
||||
internal readonly record struct ObservedEndpoint
|
||||
{
|
||||
public ObservedEndpoint(AddressFamilyKind addressFamily, string address, int port)
|
||||
{
|
||||
if (!IPAddress.TryParse(address, out IPAddress? parsed)
|
||||
|| (addressFamily == AddressFamilyKind.Ipv4 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
|| (addressFamily == AddressFamilyKind.Ipv6 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6))
|
||||
{
|
||||
throw new ArgumentException("The address must match the declared address family.", nameof(address));
|
||||
}
|
||||
|
||||
if (port is < 1 or > 65_535)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(port));
|
||||
}
|
||||
|
||||
AddressFamily = addressFamily;
|
||||
Address = parsed.ToString();
|
||||
Port = port;
|
||||
}
|
||||
|
||||
public AddressFamilyKind AddressFamily { get; }
|
||||
public string Address { get; }
|
||||
public int Port { get; }
|
||||
public bool IsValid => !string.IsNullOrEmpty(Address)
|
||||
&& Port is >= 1 and <= 65_535
|
||||
&& AddressFamily is AddressFamilyKind.Ipv4 or AddressFamilyKind.Ipv6;
|
||||
}
|
||||
|
||||
internal sealed record ListingDefinition
|
||||
{
|
||||
public required SessionListingId ListingId { get; init; }
|
||||
public required LeaseId LeaseId { get; init; }
|
||||
public required TenantScope Scope { get; init; }
|
||||
public required string OwnerSubject { get; init; }
|
||||
public required RegionId RegionId { get; init; }
|
||||
public required uint ProtocolVersion { get; init; }
|
||||
public required string BuildVersion { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public required ListingVisibility Visibility { get; init; }
|
||||
public required PublisherTrustMode TrustMode { get; init; }
|
||||
public required int CurrentPlayers { get; init; }
|
||||
public required int MaximumPlayers { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
|
||||
public required SecretFingerprint LeaseFingerprint { get; init; }
|
||||
public required MediationHandle HostPresenceHandle { get; init; }
|
||||
public required SecretFingerprint HostPresenceFingerprint { get; init; }
|
||||
public required string CapabilityDerivationSalt { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record StoredListing
|
||||
{
|
||||
public required ListingDefinition Definition { get; init; }
|
||||
public required DateTimeOffset LeaseExpiresAt { get; init; }
|
||||
public required long Version { get; init; }
|
||||
public required bool HasFreshPresence { get; init; }
|
||||
|
||||
public static ListingDefinition Freeze(ListingDefinition source) => source with
|
||||
{
|
||||
Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record CreateListingCommand(
|
||||
string IdempotencyKey,
|
||||
string RequestFingerprint,
|
||||
ListingDefinition Listing,
|
||||
int OwnerListingLimit = int.MaxValue);
|
||||
|
||||
internal sealed record RenewLeaseCommand(
|
||||
SessionListingId ListingId,
|
||||
LeaseId LeaseId,
|
||||
SecretFingerprint LeaseFingerprint,
|
||||
string OwnerSubject,
|
||||
long ExpectedVersion);
|
||||
|
||||
internal sealed record UpdateListingCommand(
|
||||
SessionListingId ListingId,
|
||||
LeaseId LeaseId,
|
||||
SecretFingerprint LeaseFingerprint,
|
||||
string OwnerSubject,
|
||||
string BuildVersion,
|
||||
string DisplayName,
|
||||
int CurrentPlayers,
|
||||
int MaximumPlayers,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
internal sealed record DeleteListingCommand(
|
||||
SessionListingId ListingId,
|
||||
LeaseId LeaseId,
|
||||
SecretFingerprint LeaseFingerprint,
|
||||
string OwnerSubject);
|
||||
|
||||
internal sealed record BindHostPresenceCommand(
|
||||
MediationHandle Handle,
|
||||
SecretFingerprint CapabilityFingerprint,
|
||||
ObservedEndpoint PublicEndpoint,
|
||||
ObservedEndpoint? LocalEndpoint);
|
||||
|
||||
internal sealed record VisibleListingQuery(
|
||||
TenantScope Scope,
|
||||
uint ProtocolVersion,
|
||||
RegionId? RegionId,
|
||||
int MaximumResults = ContractLimits.BrowserPageMaxItems,
|
||||
SessionListingId? AfterListingId = null,
|
||||
bool ExcludeFull = false);
|
||||
|
||||
internal enum AttemptPeerRole
|
||||
{
|
||||
Host = 1,
|
||||
Client = 2,
|
||||
}
|
||||
|
||||
internal sealed record CreateJoinAttemptCommand
|
||||
{
|
||||
public required string IdempotencyOwner { get; init; }
|
||||
public required string IdempotencyKey { get; init; }
|
||||
public required string RequestFingerprint { get; init; }
|
||||
public required string ClientSubject { get; init; }
|
||||
public required JoinAttemptId AttemptId { get; init; }
|
||||
public required MediationHandle MediationHandle { get; init; }
|
||||
public required TenantScope Scope { get; init; }
|
||||
public required SessionListingId ListingId { get; init; }
|
||||
public required uint ProtocolVersion { get; init; }
|
||||
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||
public required string CapabilityDerivationSalt { get; init; }
|
||||
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
||||
|
||||
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
|
||||
}
|
||||
|
||||
internal sealed record AttemptEndpointBinding(
|
||||
ObservedEndpoint PublicEndpoint,
|
||||
ObservedEndpoint? LocalEndpoint);
|
||||
|
||||
internal sealed record StoredJoinAttempt
|
||||
{
|
||||
public required JoinAttemptId AttemptId { get; init; }
|
||||
public required MediationHandle MediationHandle { get; init; }
|
||||
public required TenantScope Scope { get; init; }
|
||||
public required SessionListingId ListingId { get; init; }
|
||||
public required string ClientSubject { get; init; }
|
||||
public required uint ProtocolVersion { get; init; }
|
||||
public required string IdempotencyKey { get; init; }
|
||||
public required string RequestFingerprint { get; init; }
|
||||
public required string CapabilityDerivationSalt { get; init; }
|
||||
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
||||
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
||||
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||
public required bool IntroductionConsumed { get; init; }
|
||||
public required bool ConnectionTicketConsumed { get; init; }
|
||||
|
||||
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
||||
}
|
||||
|
||||
internal sealed record HostJoinAttemptQuery(
|
||||
SessionListingId ListingId,
|
||||
SecretFingerprint LeaseFingerprint,
|
||||
int MaximumResults,
|
||||
JoinAttemptId? AfterAttemptId = null);
|
||||
|
||||
internal sealed record BindAttemptEndpointCommand(
|
||||
MediationHandle Handle,
|
||||
AttemptPeerRole Role,
|
||||
SecretFingerprint CapabilityFingerprint,
|
||||
ObservedEndpoint PublicEndpoint,
|
||||
ObservedEndpoint? LocalEndpoint);
|
||||
|
||||
internal sealed record IntroductionEndpoints(
|
||||
StoredJoinAttempt Attempt,
|
||||
AttemptEndpointBinding Host,
|
||||
AttemptEndpointBinding Client)
|
||||
{
|
||||
public JoinAttemptId AttemptId => Attempt.AttemptId;
|
||||
}
|
||||
|
||||
internal sealed record CancelJoinAttemptCommand(
|
||||
JoinAttemptId AttemptId,
|
||||
SecretFingerprint ClientCapabilityFingerprint);
|
||||
|
||||
internal sealed record ConsumeConnectionTicketCommand(
|
||||
JoinAttemptId AttemptId,
|
||||
SecretFingerprint ConnectionTicketFingerprint);
|
||||
|
||||
internal sealed record ReplayConsumption(
|
||||
string Namespace,
|
||||
string Key,
|
||||
TimeSpan? Lifetime = null);
|
||||
|
||||
internal enum StoreResultCode
|
||||
{
|
||||
Success = 0,
|
||||
NotFound = 1,
|
||||
Expired = 2,
|
||||
Revoked = 3,
|
||||
Conflict = 4,
|
||||
CapacityExceeded = 5,
|
||||
Draining = 6,
|
||||
ReplayRejected = 7,
|
||||
ServiceUnavailable = 8,
|
||||
}
|
||||
|
||||
internal sealed record StoreResult<T>(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false)
|
||||
{
|
||||
public bool Succeeded => Code == StoreResultCode.Success;
|
||||
}
|
||||
|
||||
internal interface IEphemeralRendezvousStore
|
||||
{
|
||||
Guid InstanceId { get; }
|
||||
bool IsAvailable { get; }
|
||||
bool IsDraining { get; }
|
||||
|
||||
StoreResult<StoredListing> CreateListing(CreateListingCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredListing> RenewLease(RenewLeaseCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredListing> UpdateListing(UpdateListingCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> DeleteListing(DeleteListingCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredListing> GetListing(SessionListingId listingId, bool requireFreshPresence, CancellationToken cancellationToken = default);
|
||||
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
|
||||
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
|
||||
void BeginDrain(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,986 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly EphemeralStoreOptions _options;
|
||||
private readonly IMonotonicClock _monotonicClock;
|
||||
private readonly DateTimeOffset _wallOrigin;
|
||||
private readonly TimeSpan _monotonicOrigin;
|
||||
private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
|
||||
private readonly Dictionary<LeaseId, SessionListingId> _leases = [];
|
||||
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
|
||||
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
|
||||
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
|
||||
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
|
||||
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
|
||||
private TimeSpan? _drainDeadline;
|
||||
private bool _available = true;
|
||||
|
||||
public InMemoryEphemeralRendezvousStore(
|
||||
EphemeralStoreOptions options,
|
||||
IWallClock wallClock,
|
||||
IMonotonicClock monotonicClock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(wallClock);
|
||||
ArgumentNullException.ThrowIfNull(monotonicClock);
|
||||
options.Validate();
|
||||
_options = options;
|
||||
_monotonicClock = monotonicClock;
|
||||
_wallOrigin = wallClock.UtcNow;
|
||||
_monotonicOrigin = monotonicClock.Elapsed;
|
||||
InstanceId = Guid.NewGuid();
|
||||
}
|
||||
|
||||
public Guid InstanceId { get; }
|
||||
|
||||
public bool IsAvailable
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _available;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDraining
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _drainDeadline.HasValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public StoreResult<StoredListing> CreateListing(
|
||||
CreateListingCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ValidateListing(command.Listing);
|
||||
if (command.OwnerListingLimit <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(command), "Owner listing limit must be positive.");
|
||||
}
|
||||
|
||||
ValidateIdempotency(command.IdempotencyKey, command.RequestFingerprint);
|
||||
|
||||
StoreResult<StoredListing>? admission = CheckNewWorkAdmission<StoredListing>(command.Listing.OwnerSubject);
|
||||
if (admission is not null)
|
||||
{
|
||||
return admission;
|
||||
}
|
||||
|
||||
string idempotencyKey = $"listing:{command.Listing.Scope.GameId}:{command.Listing.Scope.EnvironmentId}:{command.Listing.OwnerSubject}:{command.IdempotencyKey}";
|
||||
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
|
||||
{
|
||||
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
if (previous.ResourceId is SessionListingId listingId
|
||||
&& _listings.TryGetValue(listingId, out ListingEntry? existing))
|
||||
{
|
||||
return new(StoreResultCode.Success, Snapshot(existing), true);
|
||||
}
|
||||
|
||||
return new(StoreResultCode.Expired);
|
||||
}
|
||||
|
||||
if (_listings.Count >= _options.MaxListings
|
||||
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
||||
|| _listings.Values.Count(entry => string.Equals(
|
||||
entry.Definition.OwnerSubject,
|
||||
command.Listing.OwnerSubject,
|
||||
StringComparison.Ordinal)) >= command.OwnerListingLimit)
|
||||
{
|
||||
return new(StoreResultCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
ListingDefinition frozen = StoredListing.Freeze(command.Listing);
|
||||
if (_listings.ContainsKey(frozen.ListingId)
|
||||
|| _leases.ContainsKey(frozen.LeaseId)
|
||||
|| HandleExists(frozen.HostPresenceHandle))
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
ListingEntry entry = new(
|
||||
frozen,
|
||||
now + _options.LeaseLifetime,
|
||||
WallDeadline(now, _options.LeaseLifetime),
|
||||
version: 1);
|
||||
_listings.Add(frozen.ListingId, entry);
|
||||
_leases.Add(frozen.LeaseId, frozen.ListingId);
|
||||
_presenceHandles.Add(frozen.HostPresenceHandle, frozen.ListingId);
|
||||
_idempotency.Add(idempotencyKey, new(
|
||||
command.RequestFingerprint,
|
||||
frozen.ListingId,
|
||||
now + _options.IdempotencyLifetime));
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredListing> RenewLease(
|
||||
RenewLeaseCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (_drainDeadline.HasValue)
|
||||
{
|
||||
return new(StoreResultCode.Draining);
|
||||
}
|
||||
|
||||
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (entry.Definition.LeaseId != command.LeaseId
|
||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|
||||
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (entry.Version != command.ExpectedVersion)
|
||||
{
|
||||
return new(StoreResultCode.Conflict, Snapshot(entry));
|
||||
}
|
||||
|
||||
entry.LeaseDeadline = now + _options.LeaseLifetime;
|
||||
entry.WallExpiresAt = WallDeadline(now, _options.LeaseLifetime);
|
||||
entry.Version++;
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredListing> UpdateListing(
|
||||
UpdateListingCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredListing>(_ =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ValidateSubject(command.OwnerSubject, nameof(command.OwnerSubject));
|
||||
if (!ContractValidation.IsBuildVersionValid(command.BuildVersion)
|
||||
|| !ContractValidation.IsDisplayNameValid(command.DisplayName)
|
||||
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
||||
|| command.CurrentPlayers < 0
|
||||
|| command.CurrentPlayers > command.MaximumPlayers
|
||||
|| !ContractValidation.IsMetadataValid(command.Metadata))
|
||||
{
|
||||
throw new ArgumentException("Listing update invariants are invalid.", nameof(command));
|
||||
}
|
||||
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (_drainDeadline.HasValue)
|
||||
{
|
||||
return new(StoreResultCode.Draining);
|
||||
}
|
||||
|
||||
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry)
|
||||
|| entry.Definition.LeaseId != command.LeaseId
|
||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|
||||
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
entry.Definition = StoredListing.Freeze(entry.Definition with
|
||||
{
|
||||
BuildVersion = command.BuildVersion,
|
||||
DisplayName = command.DisplayName,
|
||||
CurrentPlayers = command.CurrentPlayers,
|
||||
MaximumPlayers = command.MaximumPlayers,
|
||||
Metadata = command.Metadata,
|
||||
});
|
||||
entry.Version++;
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> DeleteListing(
|
||||
DeleteListingCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry)
|
||||
|| entry.Definition.LeaseId != command.LeaseId
|
||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|
||||
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
RemoveListing(command.ListingId);
|
||||
return new(StoreResultCode.Success, true);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredListing> GetListing(
|
||||
SessionListingId listingId,
|
||||
bool requireFreshPresence,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredListing>(_ =>
|
||||
{
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_listings.TryGetValue(listingId, out ListingEntry? entry))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
bool fresh = _presence.ContainsKey(entry.Definition.HostPresenceHandle);
|
||||
return requireFreshPresence && !fresh
|
||||
? new(StoreResultCode.NotFound)
|
||||
: new(StoreResultCode.Success, Snapshot(entry));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredListing> BindHostPresence(
|
||||
BindHostPresenceCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (command.Handle.Value == Guid.Empty || !command.CapabilityFingerprint.IsValid)
|
||||
{
|
||||
throw new ArgumentException("Host presence binding is invalid.", nameof(command));
|
||||
}
|
||||
|
||||
ValidateEndpoint(command.PublicEndpoint, command.LocalEndpoint);
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_presenceHandles.TryGetValue(command.Handle, out SessionListingId listingId)
|
||||
|| !_listings.TryGetValue(listingId, out ListingEntry? entry)
|
||||
|| entry.Definition.HostPresenceFingerprint != command.CapabilityFingerprint)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (!_presence.ContainsKey(command.Handle) && _presence.Count >= _options.MaxPresenceBindings)
|
||||
{
|
||||
return new(StoreResultCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
_presence[command.Handle] = new(
|
||||
command.PublicEndpoint,
|
||||
command.LocalEndpoint,
|
||||
now + _options.PresenceLifetime);
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
|
||||
VisibleListingQuery query,
|
||||
CancellationToken cancellationToken = default) => Atomic<IReadOnlyList<StoredListing>>(_ =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!IsScopeValid(query.Scope)
|
||||
|| query.ProtocolVersion == 0
|
||||
|| (query.RegionId.HasValue && string.IsNullOrEmpty(query.RegionId.Value.Value))
|
||||
|| query.MaximumResults <= 0
|
||||
|| query.MaximumResults > ContractLimits.BrowserPageMaxItems + 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(query));
|
||||
}
|
||||
|
||||
IReadOnlyList<StoredListing> visible = _listings.Values
|
||||
.Where(entry => entry.Definition.Scope == query.Scope
|
||||
&& entry.Definition.ProtocolVersion == query.ProtocolVersion
|
||||
&& entry.Definition.Visibility == ListingVisibility.Public
|
||||
&& (!query.RegionId.HasValue || entry.Definition.RegionId == query.RegionId.Value)
|
||||
&& (!query.AfterListingId.HasValue
|
||||
|| entry.Definition.ListingId.Value.CompareTo(query.AfterListingId.Value.Value) > 0)
|
||||
&& (!query.ExcludeFull
|
||||
|| entry.Definition.CurrentPlayers < entry.Definition.MaximumPlayers)
|
||||
&& _presence.ContainsKey(entry.Definition.HostPresenceHandle))
|
||||
.OrderBy(static entry => entry.Definition.ListingId.Value)
|
||||
.Take(query.MaximumResults)
|
||||
.Select(Snapshot)
|
||||
.ToArray();
|
||||
return new(StoreResultCode.Success, visible);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredJoinAttempt> CreateJoinAttempt(
|
||||
CreateJoinAttemptCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(now =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ValidateAttempt(command);
|
||||
ValidateIdempotency(command.IdempotencyKey, command.RequestFingerprint);
|
||||
ValidateSubject(command.IdempotencyOwner, nameof(command.IdempotencyOwner));
|
||||
ValidateSubject(command.ClientSubject, nameof(command.ClientSubject));
|
||||
|
||||
StoreResult<StoredJoinAttempt>? admission = CheckNewWorkAdmission<StoredJoinAttempt>(command.ClientSubject);
|
||||
if (admission is not null)
|
||||
{
|
||||
return admission;
|
||||
}
|
||||
|
||||
string idempotencyKey = $"attempt:{command.Scope.GameId}:{command.Scope.EnvironmentId}:{command.IdempotencyOwner}:{command.IdempotencyKey}";
|
||||
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
|
||||
{
|
||||
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
if (previous.ResourceId is JoinAttemptId attemptId
|
||||
&& _attempts.TryGetValue(attemptId, out AttemptEntry? priorAttempt))
|
||||
{
|
||||
return new(StoreResultCode.Success, Snapshot(priorAttempt), true);
|
||||
}
|
||||
|
||||
return new(StoreResultCode.Expired);
|
||||
}
|
||||
|
||||
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? listing)
|
||||
|| listing.Definition.Scope != command.Scope
|
||||
|| listing.Definition.ProtocolVersion != command.ProtocolVersion
|
||||
|| !_presence.ContainsKey(listing.Definition.HostPresenceHandle))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (_attempts.Count >= _options.MaxJoinAttempts
|
||||
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
||||
|| _attempts.Values.Count(entry => entry.Command.Scope == command.Scope)
|
||||
>= command.ScopeAttemptLimit)
|
||||
{
|
||||
return new(StoreResultCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
if (_attempts.ContainsKey(command.AttemptId) || HandleExists(command.MediationHandle))
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
AttemptEntry attempt = new(
|
||||
command,
|
||||
now + _options.JoinAttemptLifetime,
|
||||
WallDeadline(now, _options.JoinAttemptLifetime));
|
||||
_attempts.Add(command.AttemptId, attempt);
|
||||
_attemptHandles.Add(command.MediationHandle, command.AttemptId);
|
||||
_idempotency.Add(idempotencyKey, new(
|
||||
command.RequestFingerprint,
|
||||
command.AttemptId,
|
||||
now + _options.IdempotencyLifetime));
|
||||
return new(StoreResultCode.Success, Snapshot(attempt));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(
|
||||
HostJoinAttemptQuery query,
|
||||
CancellationToken cancellationToken = default) => Atomic<IReadOnlyList<StoredJoinAttempt>>(_ =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (query.ListingId.Value == Guid.Empty
|
||||
|| !query.LeaseFingerprint.IsValid
|
||||
|| query.MaximumResults is < 1 or > ContractLimits.BrowserPageMaxItems + 1)
|
||||
{
|
||||
throw new ArgumentException("Host attempt query invariants are invalid.", nameof(query));
|
||||
}
|
||||
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_listings.TryGetValue(query.ListingId, out ListingEntry? listing)
|
||||
|| listing.Definition.LeaseFingerprint != query.LeaseFingerprint)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values
|
||||
.Where(entry => entry.Command.ListingId == query.ListingId
|
||||
&& !entry.IntroductionConsumed
|
||||
&& (!query.AfterAttemptId.HasValue
|
||||
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
|
||||
.OrderBy(static entry => entry.Command.AttemptId.Value)
|
||||
.Take(query.MaximumResults)
|
||||
.Select(Snapshot)
|
||||
.ToArray();
|
||||
return new(StoreResultCode.Success, attempts);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> CancelJoinAttempt(
|
||||
CancelJoinAttemptCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (command.AttemptId.Value == Guid.Empty || !command.ClientCapabilityFingerprint.IsValid)
|
||||
{
|
||||
throw new ArgumentException("Join cancellation invariants are invalid.", nameof(command));
|
||||
}
|
||||
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|
||||
|| attempt.ClientCapabilityFingerprint != command.ClientCapabilityFingerprint)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
RemoveAttempt(command.AttemptId);
|
||||
return new(StoreResultCode.Success, true);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
|
||||
BindAttemptEndpointCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (command.Handle.Value == Guid.Empty
|
||||
|| command.Role is not (AttemptPeerRole.Host or AttemptPeerRole.Client)
|
||||
|| !command.CapabilityFingerprint.IsValid)
|
||||
{
|
||||
throw new ArgumentException("Attempt endpoint binding is invalid.", nameof(command));
|
||||
}
|
||||
|
||||
ValidateEndpoint(command.PublicEndpoint, command.LocalEndpoint);
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|
||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
SecretFingerprint expected = command.Role == AttemptPeerRole.Host
|
||||
? attempt.HostCapabilityFingerprint
|
||||
: attempt.ClientCapabilityFingerprint;
|
||||
if (expected != command.CapabilityFingerprint)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
AttemptEndpointBinding binding = new(command.PublicEndpoint, command.LocalEndpoint);
|
||||
AttemptEndpointBinding? current = command.Role == AttemptPeerRole.Host
|
||||
? attempt.HostEndpoint
|
||||
: attempt.ClientEndpoint;
|
||||
if (current is not null)
|
||||
{
|
||||
return current == binding
|
||||
? new(StoreResultCode.Success, Snapshot(attempt), true)
|
||||
: new(StoreResultCode.ReplayRejected);
|
||||
}
|
||||
|
||||
if (command.Role == AttemptPeerRole.Host)
|
||||
{
|
||||
attempt.HostEndpoint = binding;
|
||||
}
|
||||
else
|
||||
{
|
||||
attempt.ClientEndpoint = binding;
|
||||
}
|
||||
|
||||
return new(StoreResultCode.Success, Snapshot(attempt));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
|
||||
MediationHandle handle,
|
||||
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(now =>
|
||||
{
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|
||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (attempt.IntroductionConsumed)
|
||||
{
|
||||
return new(StoreResultCode.ReplayRejected);
|
||||
}
|
||||
|
||||
if (attempt.HostEndpoint is null || attempt.ClientEndpoint is null)
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
attempt.IntroductionConsumed = true;
|
||||
TimeSpan ticketLifetime = TimeSpan.FromTicks(Math.Min(
|
||||
_options.ConnectionTicketLifetime.Ticks,
|
||||
(attempt.Deadline - now).Ticks));
|
||||
attempt.TicketDeadline = now + ticketLifetime;
|
||||
attempt.TicketWallExpiresAt = WallDeadline(now, ticketLifetime);
|
||||
return new(StoreResultCode.Success, new(
|
||||
Snapshot(attempt),
|
||||
attempt.HostEndpoint,
|
||||
attempt.ClientEndpoint));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> ConsumeConnectionTicket(
|
||||
ConsumeConnectionTicketCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (command.AttemptId.Value == Guid.Empty || !command.ConnectionTicketFingerprint.IsValid)
|
||||
{
|
||||
throw new ArgumentException("Connection ticket invariants are invalid.", nameof(command));
|
||||
}
|
||||
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|
||||
|| attempt.ConnectionTicketFingerprint != command.ConnectionTicketFingerprint)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (!attempt.IntroductionConsumed)
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now)
|
||||
{
|
||||
return new(StoreResultCode.Expired);
|
||||
}
|
||||
|
||||
if (attempt.ConnectionTicketConsumed)
|
||||
{
|
||||
return new(StoreResultCode.ReplayRejected);
|
||||
}
|
||||
|
||||
attempt.ConnectionTicketConsumed = true;
|
||||
return new(StoreResultCode.Success, true);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> ConsumeReplay(
|
||||
ReplayConsumption consumption,
|
||||
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(consumption);
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
ValidateReplay(consumption);
|
||||
string key = $"{consumption.Namespace}:{consumption.Key}";
|
||||
if (_replay.ContainsKey(key))
|
||||
{
|
||||
return new(StoreResultCode.ReplayRejected);
|
||||
}
|
||||
|
||||
if (_replay.Count >= _options.MaxReplayEntries)
|
||||
{
|
||||
return new(StoreResultCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
TimeSpan lifetime = consumption.Lifetime ?? _options.ReplayLifetime;
|
||||
if (lifetime <= TimeSpan.Zero || lifetime > _options.ReplayLifetime)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(consumption), "Replay lifetime exceeds the configured ceiling.");
|
||||
}
|
||||
|
||||
_replay.Add(key, now + lifetime);
|
||||
return new(StoreResultCode.Success, true);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> RevokeListing(
|
||||
SessionListingId listingId,
|
||||
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
|
||||
{
|
||||
if (!_listings.ContainsKey(listingId))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
RemoveListing(listingId);
|
||||
return new(StoreResultCode.Success, true);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<int> RevokePrincipal(
|
||||
string subject,
|
||||
TimeSpan lifetime,
|
||||
CancellationToken cancellationToken = default) => Atomic<int>(now =>
|
||||
{
|
||||
ValidateSubject(subject, nameof(subject));
|
||||
if (lifetime <= TimeSpan.Zero || lifetime > TimeSpan.FromMinutes(10))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(lifetime));
|
||||
}
|
||||
|
||||
if (!_revocations.ContainsKey(subject) && _revocations.Count >= _options.MaxRevocations)
|
||||
{
|
||||
return new(StoreResultCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
_revocations[subject] = now + lifetime;
|
||||
SessionListingId[] listings = _listings
|
||||
.Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal))
|
||||
.Select(static item => item.Key)
|
||||
.ToArray();
|
||||
JoinAttemptId[] attempts = _attempts
|
||||
.Where(item => string.Equals(item.Value.Command.ClientSubject, subject, StringComparison.Ordinal))
|
||||
.Select(static item => item.Key)
|
||||
.ToArray();
|
||||
foreach (SessionListingId listingId in listings)
|
||||
{
|
||||
RemoveListing(listingId);
|
||||
}
|
||||
|
||||
foreach (JoinAttemptId attemptId in attempts)
|
||||
{
|
||||
RemoveAttempt(attemptId);
|
||||
}
|
||||
|
||||
return new(StoreResultCode.Success, listings.Length + attempts.Length);
|
||||
}, cancellationToken);
|
||||
|
||||
public void BeginDrain(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
lock (_gate)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!_drainDeadline.HasValue)
|
||||
{
|
||||
_drainDeadline = _monotonicClock.Elapsed + _options.GracefulDrainLifetime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void MarkUnavailable()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_available = false;
|
||||
ClearActiveState();
|
||||
}
|
||||
}
|
||||
|
||||
private StoreResult<T> Atomic<T>(Func<TimeSpan, StoreResult<T>> operation, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
lock (_gate)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
TimeSpan now = _monotonicClock.Elapsed;
|
||||
Cleanup(now);
|
||||
return operation(now);
|
||||
}
|
||||
}
|
||||
|
||||
private StoreResult<T>? CheckNewWorkAdmission<T>(string subject)
|
||||
{
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (_drainDeadline.HasValue)
|
||||
{
|
||||
return new(StoreResultCode.Draining);
|
||||
}
|
||||
|
||||
return _revocations.ContainsKey(subject)
|
||||
? new(StoreResultCode.Revoked)
|
||||
: null;
|
||||
}
|
||||
|
||||
private void Cleanup(TimeSpan now)
|
||||
{
|
||||
if (_drainDeadline is TimeSpan drainDeadline && now >= drainDeadline)
|
||||
{
|
||||
ClearActiveState();
|
||||
}
|
||||
|
||||
RemoveExpired(_revocations, now);
|
||||
RemoveExpired(_replay, now);
|
||||
foreach (string key in _idempotency
|
||||
.Where(item => item.Value.Deadline <= now)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
{
|
||||
_idempotency.Remove(key);
|
||||
}
|
||||
|
||||
foreach (MediationHandle handle in _presence
|
||||
.Where(item => item.Value.Deadline <= now)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
{
|
||||
_presence.Remove(handle);
|
||||
}
|
||||
|
||||
foreach (JoinAttemptId attemptId in _attempts
|
||||
.Where(item => item.Value.Deadline <= now)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
{
|
||||
RemoveAttempt(attemptId);
|
||||
}
|
||||
|
||||
foreach (SessionListingId listingId in _listings
|
||||
.Where(item => item.Value.LeaseDeadline <= now)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
{
|
||||
RemoveListing(listingId);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearActiveState()
|
||||
{
|
||||
_listings.Clear();
|
||||
_leases.Clear();
|
||||
_presenceHandles.Clear();
|
||||
_presence.Clear();
|
||||
_attempts.Clear();
|
||||
_attemptHandles.Clear();
|
||||
_idempotency.Clear();
|
||||
_replay.Clear();
|
||||
}
|
||||
|
||||
private void RemoveListing(SessionListingId listingId)
|
||||
{
|
||||
if (!_listings.Remove(listingId, out ListingEntry? listing))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_leases.Remove(listing.Definition.LeaseId);
|
||||
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
|
||||
_presence.Remove(listing.Definition.HostPresenceHandle);
|
||||
foreach (JoinAttemptId attemptId in _attempts
|
||||
.Where(item => item.Value.Command.ListingId == listingId)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
{
|
||||
RemoveAttempt(attemptId);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveAttempt(JoinAttemptId attemptId)
|
||||
{
|
||||
if (_attempts.Remove(attemptId, out AttemptEntry? attempt))
|
||||
{
|
||||
_attemptHandles.Remove(attempt.Command.MediationHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private bool HandleExists(MediationHandle handle) =>
|
||||
_presenceHandles.ContainsKey(handle) || _attemptHandles.ContainsKey(handle);
|
||||
|
||||
private DateTimeOffset WallDeadline(TimeSpan now, TimeSpan lifetime) =>
|
||||
_wallOrigin + (now - _monotonicOrigin) + lifetime;
|
||||
|
||||
private StoredListing Snapshot(ListingEntry entry) => new()
|
||||
{
|
||||
Definition = entry.Definition,
|
||||
LeaseExpiresAt = entry.WallExpiresAt,
|
||||
Version = entry.Version,
|
||||
HasFreshPresence = _presence.ContainsKey(entry.Definition.HostPresenceHandle),
|
||||
};
|
||||
|
||||
private static StoredJoinAttempt Snapshot(AttemptEntry entry) => new()
|
||||
{
|
||||
AttemptId = entry.Command.AttemptId,
|
||||
MediationHandle = entry.Command.MediationHandle,
|
||||
Scope = entry.Command.Scope,
|
||||
ListingId = entry.Command.ListingId,
|
||||
ClientSubject = entry.Command.ClientSubject,
|
||||
ProtocolVersion = entry.Command.ProtocolVersion,
|
||||
IdempotencyKey = entry.Command.IdempotencyKey,
|
||||
RequestFingerprint = entry.Command.RequestFingerprint,
|
||||
CapabilityDerivationSalt = entry.Command.CapabilityDerivationSalt,
|
||||
HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint,
|
||||
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
|
||||
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
|
||||
ExpiresAt = entry.WallExpiresAt,
|
||||
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
|
||||
HostEndpoint = entry.HostEndpoint,
|
||||
ClientEndpoint = entry.ClientEndpoint,
|
||||
IntroductionConsumed = entry.IntroductionConsumed,
|
||||
ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
|
||||
};
|
||||
|
||||
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
||||
{
|
||||
foreach (string key in entries
|
||||
.Where(item => item.Value <= now)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
{
|
||||
entries.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateListing(ListingDefinition listing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(listing);
|
||||
ValidateSubject(listing.OwnerSubject, nameof(listing.OwnerSubject));
|
||||
ArgumentNullException.ThrowIfNull(listing.Metadata);
|
||||
if (listing.ListingId.Value == Guid.Empty
|
||||
|| listing.LeaseId.Value == Guid.Empty
|
||||
|| listing.HostPresenceHandle.Value == Guid.Empty
|
||||
|| !IsScopeValid(listing.Scope)
|
||||
|| string.IsNullOrEmpty(listing.RegionId.Value)
|
||||
|| listing.ProtocolVersion == 0
|
||||
|| !ContractValidation.IsBuildVersionValid(listing.BuildVersion)
|
||||
|| !ContractValidation.IsDisplayNameValid(listing.DisplayName)
|
||||
|| !Enum.IsDefined(listing.Visibility)
|
||||
|| !Enum.IsDefined(listing.TrustMode)
|
||||
|| listing.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
||||
|| listing.CurrentPlayers < 0
|
||||
|| listing.CurrentPlayers > listing.MaximumPlayers
|
||||
|| !ContractValidation.IsMetadataValid(listing.Metadata)
|
||||
|| !listing.LeaseFingerprint.IsValid
|
||||
|| !listing.HostPresenceFingerprint.IsValid
|
||||
|| !IsDerivationSaltValid(listing.CapabilityDerivationSalt))
|
||||
{
|
||||
throw new ArgumentException("Listing invariants are invalid.", nameof(listing));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateIdempotency(string key, string requestFingerprint)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || key.Length > 128)
|
||||
{
|
||||
throw new ArgumentException("Idempotency keys must contain 1-128 characters.", nameof(key));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestFingerprint) || requestFingerprint.Length > 128)
|
||||
{
|
||||
throw new ArgumentException("Request fingerprints must contain 1-128 characters.", nameof(requestFingerprint));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateReplay(ReplayConsumption consumption)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(consumption.Namespace) || consumption.Namespace.Length > 64
|
||||
|| string.IsNullOrWhiteSpace(consumption.Key) || consumption.Key.Length > 128)
|
||||
{
|
||||
throw new ArgumentException("Replay namespace/key is invalid.", nameof(consumption));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAttempt(CreateJoinAttemptCommand command)
|
||||
{
|
||||
if (command.AttemptId.Value == Guid.Empty
|
||||
|| command.MediationHandle.Value == Guid.Empty
|
||||
|| command.ListingId.Value == Guid.Empty
|
||||
|| !IsScopeValid(command.Scope)
|
||||
|| command.ProtocolVersion == 0
|
||||
|| !command.HostCapabilityFingerprint.IsValid
|
||||
|| !command.ClientCapabilityFingerprint.IsValid
|
||||
|| !command.ConnectionTicketFingerprint.IsValid
|
||||
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|
||||
|| command.ScopeAttemptLimit <= 0)
|
||||
{
|
||||
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEndpoint(ObservedEndpoint publicEndpoint, ObservedEndpoint? localEndpoint)
|
||||
{
|
||||
if (!publicEndpoint.IsValid || (localEndpoint.HasValue && !localEndpoint.Value.IsValid))
|
||||
{
|
||||
throw new ArgumentException("Observed endpoints must be valid immutable endpoint values.", nameof(publicEndpoint));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsScopeValid(TenantScope scope) =>
|
||||
!string.IsNullOrEmpty(scope.GameId.Value) && !string.IsNullOrEmpty(scope.EnvironmentId.Value);
|
||||
|
||||
private static bool IsDerivationSaltValid(string? value) => value is not null
|
||||
&& value.Length == 43
|
||||
&& value.All(static character =>
|
||||
character is >= 'A' and <= 'Z'
|
||||
or >= 'a' and <= 'z'
|
||||
or >= '0' and <= '9'
|
||||
or '-'
|
||||
or '_');
|
||||
|
||||
private static void ValidateSubject(string subject, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject) || subject.Length > 256)
|
||||
{
|
||||
throw new ArgumentException("Subjects must contain 1-256 characters.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ListingEntry(
|
||||
ListingDefinition definition,
|
||||
TimeSpan leaseDeadline,
|
||||
DateTimeOffset wallExpiresAt,
|
||||
long version)
|
||||
{
|
||||
public ListingDefinition Definition { get; set; } = definition;
|
||||
public TimeSpan LeaseDeadline { get; set; } = leaseDeadline;
|
||||
public DateTimeOffset WallExpiresAt { get; set; } = wallExpiresAt;
|
||||
public long Version { get; set; } = version;
|
||||
}
|
||||
|
||||
private sealed class PresenceEntry(
|
||||
ObservedEndpoint publicEndpoint,
|
||||
ObservedEndpoint? localEndpoint,
|
||||
TimeSpan deadline)
|
||||
{
|
||||
public ObservedEndpoint PublicEndpoint { get; } = publicEndpoint;
|
||||
public ObservedEndpoint? LocalEndpoint { get; } = localEndpoint;
|
||||
public TimeSpan Deadline { get; } = deadline;
|
||||
}
|
||||
|
||||
private sealed class AttemptEntry(
|
||||
CreateJoinAttemptCommand command,
|
||||
TimeSpan deadline,
|
||||
DateTimeOffset wallExpiresAt)
|
||||
{
|
||||
public CreateJoinAttemptCommand Command { get; } = command;
|
||||
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
|
||||
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
|
||||
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
|
||||
public TimeSpan Deadline { get; } = deadline;
|
||||
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
|
||||
public TimeSpan? TicketDeadline { get; set; }
|
||||
public DateTimeOffset? TicketWallExpiresAt { get; set; }
|
||||
public AttemptEndpointBinding? HostEndpoint { get; set; }
|
||||
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
||||
public bool IntroductionConsumed { get; set; }
|
||||
public bool ConnectionTicketConsumed { get; set; }
|
||||
}
|
||||
|
||||
private sealed record IdempotencyEntry(
|
||||
string RequestFingerprint,
|
||||
object ResourceId,
|
||||
TimeSpan Deadline);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
internal static class StoreResultMapping
|
||||
{
|
||||
public static RendezvousErrorCode ToContractError(this StoreResultCode code) => code switch
|
||||
{
|
||||
StoreResultCode.Success => RendezvousErrorCode.None,
|
||||
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
|
||||
StoreResultCode.Expired => RendezvousErrorCode.Expired,
|
||||
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
|
||||
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
|
||||
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
|
||||
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
|
||||
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
_ => RendezvousErrorCode.InternalError,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||
@@ -7,10 +10,12 @@ namespace FinalFactory.Rendezvous.Server.Transport;
|
||||
/// <summary>
|
||||
/// Owns the cancellable UDP socket used by the future NAT mediator.
|
||||
/// </summary>
|
||||
public sealed partial class UdpMediatorService : BackgroundService
|
||||
internal sealed partial class UdpMediatorService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<UdpMediatorService> _logger;
|
||||
private readonly UdpMediatorOptions _options;
|
||||
private readonly IEphemeralRendezvousStore _store;
|
||||
private readonly ISessionCapabilityService _capabilities;
|
||||
private UdpClient? _udpClient;
|
||||
|
||||
/// <summary>
|
||||
@@ -18,10 +23,14 @@ public sealed partial class UdpMediatorService : BackgroundService
|
||||
/// </summary>
|
||||
public UdpMediatorService(
|
||||
IOptions<UdpMediatorOptions> options,
|
||||
ILogger<UdpMediatorService> logger)
|
||||
ILogger<UdpMediatorService> logger,
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_store = store;
|
||||
_capabilities = capabilities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,7 +90,10 @@ public sealed partial class UdpMediatorService : BackgroundService
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_ = await udpClient.ReceiveAsync(stoppingToken).ConfigureAwait(false);
|
||||
UdpReceiveResult received = await udpClient
|
||||
.ReceiveAsync(stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
ProcessDatagram(received.Buffer, received.RemoteEndPoint, stoppingToken);
|
||||
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
|
||||
}
|
||||
}
|
||||
@@ -99,6 +111,53 @@ public sealed partial class UdpMediatorService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
internal UdpPresenceProcessingResult ProcessDatagram(
|
||||
ReadOnlySpan<byte> encoded,
|
||||
IPEndPoint observedSource,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observedSource);
|
||||
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||
|| datagram is null
|
||||
|| !_capabilities.TryFingerprint(datagram.Capability, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return UdpPresenceProcessingResult.Dropped;
|
||||
}
|
||||
|
||||
if (datagram.MessageType != UdpPresenceMessageType.HostPresence)
|
||||
{
|
||||
return UdpPresenceProcessingResult.ClientPresenceDeferred;
|
||||
}
|
||||
|
||||
AddressFamilyKind publicFamily = observedSource.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||
_ => 0,
|
||||
};
|
||||
if (publicFamily == 0)
|
||||
{
|
||||
return UdpPresenceProcessingResult.Dropped;
|
||||
}
|
||||
|
||||
ObservedEndpoint publicEndpoint = new(
|
||||
publicFamily,
|
||||
observedSource.Address.ToString(),
|
||||
observedSource.Port);
|
||||
ObservedEndpoint localEndpoint = new(
|
||||
datagram.AddressFamily,
|
||||
datagram.LocalAddress,
|
||||
datagram.LocalPort);
|
||||
StoreResult<StoredListing> bound = _store.BindHostPresence(new(
|
||||
datagram.MediationHandle,
|
||||
fingerprint,
|
||||
publicEndpoint,
|
||||
localEndpoint), cancellationToken);
|
||||
return bound.Succeeded
|
||||
? UdpPresenceProcessingResult.HostPresenceAccepted
|
||||
: UdpPresenceProcessingResult.HostPresenceRejected;
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1,
|
||||
Level = LogLevel.Information,
|
||||
@@ -114,3 +173,11 @@ public sealed partial class UdpMediatorService : BackgroundService
|
||||
Message = "UDP mediator stopped")]
|
||||
private static partial void LogMediatorStopped(ILogger logger);
|
||||
}
|
||||
|
||||
internal enum UdpPresenceProcessingResult
|
||||
{
|
||||
Dropped = 0,
|
||||
HostPresenceAccepted = 1,
|
||||
HostPresenceRejected = 2,
|
||||
ClientPresenceDeferred = 3,
|
||||
}
|
||||
|
||||
@@ -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,153 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||
|
||||
public sealed class SessionBrowserServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ListEnforcesTenantProtocolPresenceVisibilityAndAvailabilityFilters()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
StoredListing eligible = fixture.Add();
|
||||
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
|
||||
fixture.Add(scope: new(fixture.Scope.GameId, new("other-env")));
|
||||
fixture.Add(protocolVersion: 8);
|
||||
fixture.Add(regionId: new("us-east"));
|
||||
fixture.Add(visibility: ListingVisibility.Unlisted);
|
||||
fixture.Add(fresh: false);
|
||||
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
|
||||
BrowseSessionsRequest request = fixture.Request();
|
||||
request.ExcludeFull = true;
|
||||
|
||||
BrowserServiceResult<BrowseSessionsResponse> result = fixture.Browser.Browse(request);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Collection(result.Value!.Items, item => Assert.Equal(eligible.Definition.ListingId, item.ListingId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnguessableIdRetrievalAllowsFreshUnlistedOnlyWithinExactScope()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
StoredListing unlisted = fixture.Add(visibility: ListingVisibility.Unlisted);
|
||||
|
||||
Assert.True(fixture.Browser.Get(
|
||||
unlisted.Definition.ListingId,
|
||||
fixture.Scope.GameId,
|
||||
fixture.Scope.EnvironmentId,
|
||||
7).Succeeded);
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||
unlisted.Definition.ListingId,
|
||||
new("other-game"),
|
||||
fixture.Scope.EnvironmentId,
|
||||
7).Error);
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||
unlisted.Definition.ListingId,
|
||||
fixture.Scope.GameId,
|
||||
fixture.Scope.EnvironmentId,
|
||||
8).Error);
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||
unlisted.Definition.ListingId,
|
||||
fixture.Scope.GameId,
|
||||
fixture.Scope.EnvironmentId,
|
||||
7).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeysetCursorReturnsStableRecordsOnceAndRejectsTamperingOrRescoping()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
for (int index = 0; index < 7; index++)
|
||||
{
|
||||
fixture.Add();
|
||||
}
|
||||
|
||||
BrowseSessionsRequest request = fixture.Request(pageSize: 2);
|
||||
List<SessionListingId> seen = [];
|
||||
do
|
||||
{
|
||||
BrowseSessionsResponse page = fixture.Browser.Browse(request).Value!;
|
||||
seen.AddRange(page.Items.Select(static item => item.ListingId));
|
||||
request.Cursor = page.NextCursor;
|
||||
}
|
||||
while (request.Cursor is not null);
|
||||
|
||||
Assert.Equal(7, seen.Count);
|
||||
Assert.Equal(7, seen.Distinct().Count());
|
||||
Assert.Equal(seen.OrderBy(static id => id.Value), seen);
|
||||
|
||||
BrowseSessionsRequest tampered = fixture.Request(pageSize: 2);
|
||||
tampered.Cursor = fixture.Browser.Browse(tampered).Value!.NextCursor + "A";
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(tampered).Error);
|
||||
BrowseSessionsRequest rescoped = fixture.Request(pageSize: 2);
|
||||
rescoped.Cursor = fixture.Browser.Browse(fixture.Request(pageSize: 2)).Value!.NextCursor;
|
||||
rescoped.ExcludeFull = true;
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(rescoped).Error);
|
||||
|
||||
BrowseSessionsRequest expired = fixture.Request(pageSize: 2);
|
||||
expired.Cursor = fixture.Browser.Browse(expired).Value!.NextCursor;
|
||||
fixture.Clock.Advance(TimeSpan.FromMinutes(5));
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(expired).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseByteBudgetTrimsLargePagesAndContinuesWithCursor()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
Dictionary<string, string> metadata = Enumerable.Range(0, 14).ToDictionary(
|
||||
static index => $"key-{index}",
|
||||
static index => new string((char)('a' + index % 26), 256),
|
||||
EqualityComparer<string>.Default);
|
||||
for (int index = 0; index < 100; index++)
|
||||
{
|
||||
fixture.Add(metadata: metadata);
|
||||
}
|
||||
|
||||
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
|
||||
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||
|
||||
Assert.InRange(encodedBytes, 1, ContractLimits.BrowserResponseMaxBytes);
|
||||
Assert.NotEmpty(response.Items);
|
||||
Assert.NotNull(response.NextCursor);
|
||||
Assert.True(response.Items.Count < 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresentationMetadataIsJsonEscapedAndResponseHasNoConnectionSecrets()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
fixture.Add(metadata: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["mode"] = "co-op",
|
||||
["map"] = "<script>alert(1)</script>",
|
||||
});
|
||||
|
||||
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
|
||||
string json = JsonSerializer.Serialize(response, ContractJson.Options);
|
||||
|
||||
Assert.DoesNotContain("<script>", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("token", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("capability", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("<script>alert(1)</script>", Assert.Single(response.Items).Metadata["map"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevokedListingDisappearsBeforeAnotherReadPathCanObserveIt()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
StoredListing listing = fixture.Add();
|
||||
fixture.Store.RevokeListing(listing.Definition.ListingId);
|
||||
|
||||
Assert.Empty(fixture.Browser.Browse(fixture.Request()).Value!.Items);
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||
listing.Definition.ListingId,
|
||||
fixture.Scope.GameId,
|
||||
fixture.Scope.EnvironmentId,
|
||||
7).Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||
|
||||
internal sealed class SessionBrowserFixture : IDisposable
|
||||
{
|
||||
private readonly EphemeralStateFixture _state = new();
|
||||
|
||||
public SessionBrowserFixture()
|
||||
{
|
||||
Cursors = new();
|
||||
Browser = new(_state.Store, Cursors, _state.Clock);
|
||||
}
|
||||
|
||||
public InMemoryEphemeralRendezvousStore Store => _state.Store;
|
||||
public ManualRendezvousClock Clock => _state.Clock;
|
||||
public SessionBrowserCursorCodec Cursors { get; }
|
||||
public SessionBrowserService Browser { get; }
|
||||
public TenantScope Scope => _state.Scope;
|
||||
|
||||
public StoredListing Add(
|
||||
TenantScope? scope = null,
|
||||
uint protocolVersion = 7,
|
||||
RegionId? regionId = null,
|
||||
ListingVisibility visibility = ListingVisibility.Public,
|
||||
bool fresh = true,
|
||||
int currentPlayers = 1,
|
||||
int maximumPlayers = 8,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
CreateListingCommand seed = _state.ListingCommand();
|
||||
CreateListingCommand command = seed with
|
||||
{
|
||||
Listing = seed.Listing with
|
||||
{
|
||||
Scope = scope ?? Scope,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId ?? seed.Listing.RegionId,
|
||||
Visibility = visibility,
|
||||
CurrentPlayers = currentPlayers,
|
||||
MaximumPlayers = maximumPlayers,
|
||||
Metadata = metadata ?? seed.Listing.Metadata,
|
||||
},
|
||||
};
|
||||
StoredListing listing = Store.CreateListing(command).Value!;
|
||||
if (fresh)
|
||||
{
|
||||
listing = Store.BindHostPresence(new(
|
||||
command.Listing.HostPresenceHandle,
|
||||
command.Listing.HostPresenceFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(40_000),
|
||||
null)).Value!;
|
||||
}
|
||||
|
||||
return listing;
|
||||
}
|
||||
|
||||
public BrowseSessionsRequest Request(int pageSize = 100) => new()
|
||||
{
|
||||
GameId = Scope.GameId,
|
||||
EnvironmentId = Scope.EnvironmentId,
|
||||
ProtocolVersion = 7,
|
||||
RegionId = new("eu-central"),
|
||||
PageSize = pageSize,
|
||||
};
|
||||
|
||||
public void Dispose() => Cursors.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class ConnectionTicketValidatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void AuthorizedTicketIsAcceptedExactlyOnce()
|
||||
{
|
||||
ManualConnectionTicketClock clock = new();
|
||||
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||
JoinAttemptId attempt = NewAttempt();
|
||||
string ticket = Ticket('A');
|
||||
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.Accepted,
|
||||
validator.Consume(attempt, ticket));
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.AlreadyConsumed,
|
||||
validator.Consume(attempt, ticket));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlteredCrossAttemptExpiredAndRevokedTicketsAreRejected()
|
||||
{
|
||||
ManualConnectionTicketClock clock = new();
|
||||
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||
JoinAttemptId first = NewAttempt();
|
||||
JoinAttemptId second = NewAttempt();
|
||||
Assert.True(validator.TryAuthorize(first, Ticket('A'), Now.AddSeconds(20)));
|
||||
Assert.True(validator.TryAuthorize(second, Ticket('B'), Now.AddSeconds(40)));
|
||||
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.Rejected,
|
||||
validator.Consume(first, Ticket('B')));
|
||||
clock.Advance(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.Expired,
|
||||
validator.Consume(first, Ticket('A')));
|
||||
Assert.True(validator.Revoke(second));
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.Revoked,
|
||||
validator.Consume(second, Ticket('B')));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentConsumptionHasOneWinner()
|
||||
{
|
||||
ManualConnectionTicketClock clock = new();
|
||||
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||
JoinAttemptId attempt = NewAttempt();
|
||||
string ticket = Ticket('C');
|
||||
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||
using ManualResetEventSlim start = new(false);
|
||||
Task<ConnectionTicketConsumptionResult> left = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return validator.Consume(attempt, ticket);
|
||||
});
|
||||
Task<ConnectionTicketConsumptionResult> right = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return validator.Consume(attempt, ticket);
|
||||
});
|
||||
|
||||
start.Set();
|
||||
ConnectionTicketConsumptionResult[] results = await Task.WhenAll(left, right);
|
||||
|
||||
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.Accepted);
|
||||
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.AlreadyConsumed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorIsBoundedDisposableAndRedacted()
|
||||
{
|
||||
ManualConnectionTicketClock clock = new();
|
||||
ConnectionTicketValidator validator = new(1, clock);
|
||||
Assert.True(validator.TryAuthorize(NewAttempt(), Ticket('A'), Now.AddSeconds(20)));
|
||||
Assert.False(validator.TryAuthorize(NewAttempt(), Ticket('B'), Now.AddSeconds(20)));
|
||||
Assert.DoesNotContain(Ticket('A'), validator.ToString(), StringComparison.Ordinal);
|
||||
|
||||
validator.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(() => validator.Revoke(NewAttempt()));
|
||||
Assert.Throws<ObjectDisposedException>(() => validator.Consume(default, string.Empty));
|
||||
}
|
||||
|
||||
private static JoinAttemptId NewAttempt() => new(Guid.NewGuid());
|
||||
private static string Ticket(char value) => new(value, 43);
|
||||
|
||||
private sealed class ManualConnectionTicketClock : IConnectionTicketClock
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; set; } = Now;
|
||||
|
||||
public void Advance(TimeSpan duration) => UtcNow += duration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class RendezvousClientBehaviorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RegistrationRetriesWithTheSameIdempotentPayloadAndDisposesResponses()
|
||||
{
|
||||
TrackingContent unavailable = JsonContent(new ApiError
|
||||
{
|
||||
Code = RendezvousErrorCode.ServiceUnavailable,
|
||||
Message = "try later",
|
||||
RetryAfterSeconds = 1,
|
||||
});
|
||||
TrackingContent created = JsonContent(CreateRegistrationResponse());
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.ServiceUnavailable, unavailable),
|
||||
Response(HttpStatusCode.Created, created),
|
||||
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RegisterSessionRequest request = CreateRegistrationRequest("stable-idempotency-key");
|
||||
RecordingDelay delay = new(() => request.DisplayName = "mutated during retry delay");
|
||||
RendezvousPublisherClient publisher = new(
|
||||
httpClient,
|
||||
new RendezvousClientOptions { JitterRatio = 0 },
|
||||
delay);
|
||||
|
||||
RendezvousClientResult<PublishedSession> result = await publisher.RegisterAsync(
|
||||
request,
|
||||
"publisher-credential");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, handler.RequestBodies.Count);
|
||||
Assert.Equal(handler.RequestBodies[0], handler.RequestBodies[1]);
|
||||
Assert.Contains("stable-idempotency-key", handler.RequestBodies[0], StringComparison.Ordinal);
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), Assert.Single(delay.Delays));
|
||||
Assert.True(unavailable.IsDisposed);
|
||||
Assert.True(created.IsDisposed);
|
||||
|
||||
using HttpResponseMessage stillOwnedByCaller = await httpClient.GetAsync("health");
|
||||
Assert.Equal(HttpStatusCode.NoContent, stillOwnedByCaller.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUsesTheLeaseWithoutMutatingTheCallersRequest()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
|
||||
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RendezvousPublisherClient publisher = new(httpClient);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistrationRequest("update-idempotency-key"),
|
||||
"publisher-credential"));
|
||||
UpdateSessionRequest update = new()
|
||||
{
|
||||
LeaseToken = "caller-placeholder",
|
||||
BuildVersion = "2.0.0",
|
||||
DisplayName = "updated",
|
||||
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 4 },
|
||||
Metadata = new() { ["mode"] = "online-coop" },
|
||||
};
|
||||
|
||||
RendezvousClientResult<bool> result = await publisher.UpdateAsync(
|
||||
session,
|
||||
update,
|
||||
"publisher-credential");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("caller-placeholder", update.LeaseToken);
|
||||
Assert.Contains("lease-token", handler.RequestBodies[1], StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("caller-placeholder", handler.RequestBodies[1], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayFailureIsRetriedForSafeBrowserReads()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
new HttpResponseMessage(HttpStatusCode.BadGateway),
|
||||
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse())));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RecordingDelay delay = new();
|
||||
RendezvousSessionBrowserClient browser = new(
|
||||
httpClient,
|
||||
new RendezvousClientOptions { JitterRatio = 0 },
|
||||
delay);
|
||||
|
||||
RendezvousClientResult<BrowseSessionsResponse> result = await browser.BrowseAsync(new()
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
});
|
||||
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
Assert.Equal(2, handler.RequestUris.Count);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessResultRequiresAValue()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => RendezvousClientResult.Success<string>(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseAllFollowsCursorsWithoutMutatingTheCallersRequest()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
|
||||
{
|
||||
Items = [CreateListing("00000000-0000-0000-0000-000000000001")],
|
||||
NextCursor = "next page+token",
|
||||
})),
|
||||
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
|
||||
{
|
||||
Items = [CreateListing("00000000-0000-0000-0000-000000000002")],
|
||||
})));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||
BrowseSessionsRequest request = new()
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
PageSize = 1,
|
||||
};
|
||||
|
||||
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(request);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value!.Count);
|
||||
Assert.Null(request.Cursor);
|
||||
Assert.DoesNotContain("cursor=", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||
Assert.Contains("cursor=next%20page%2Btoken", handler.RequestUris[1].Query, StringComparison.Ordinal);
|
||||
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeaseMaintainerReportsLeaseLoss()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
|
||||
Response(HttpStatusCode.Gone, JsonContent(new ApiError
|
||||
{
|
||||
Code = RendezvousErrorCode.Expired,
|
||||
Message = "lease expired",
|
||||
})));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RecordingDelay delay = new();
|
||||
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistrationRequest("lease-loss-key"),
|
||||
"publisher-credential"));
|
||||
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||
session,
|
||||
"publisher-credential");
|
||||
bool eventRaised = false;
|
||||
maintainer.LeaseLost += (_, _) => eventRaised = true;
|
||||
|
||||
LeaseMaintenanceResult result = await maintainer.RunAsync();
|
||||
|
||||
Assert.Equal(LeaseMaintenanceStopReason.LeaseLost, result.Reason);
|
||||
Assert.Equal(RendezvousErrorCode.Expired, result.Error);
|
||||
Assert.True(eventRaised);
|
||||
Assert.Equal(TimeSpan.FromSeconds(15), Assert.Single(delay.Delays));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposingLeaseMaintainerCancelsItsWaitAndDoesNotRenew()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
BlockingDelay delay = new();
|
||||
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistrationRequest("dispose-key"),
|
||||
"publisher-credential"));
|
||||
SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||
session,
|
||||
"publisher-credential");
|
||||
Task<LeaseMaintenanceResult> active = maintainer.RunAsync();
|
||||
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
await maintainer.DisposeAsync();
|
||||
LeaseMaintenanceResult result = await active;
|
||||
|
||||
Assert.Equal(LeaseMaintenanceStopReason.Disposed, result.Reason);
|
||||
Assert.Single(handler.RequestUris);
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => maintainer.RunAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallerCancellationStopsLeaseMaintenanceWithoutRenewing()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
BlockingDelay delay = new();
|
||||
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistrationRequest("cancel-key"),
|
||||
"publisher-credential"));
|
||||
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||
session,
|
||||
"publisher-credential");
|
||||
using CancellationTokenSource cancellation = new();
|
||||
Task<LeaseMaintenanceResult> active = maintainer.RunAsync(cancellation.Token);
|
||||
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
await cancellation.CancelAsync();
|
||||
LeaseMaintenanceResult result = await active;
|
||||
|
||||
Assert.Equal(LeaseMaintenanceStopReason.Cancelled, result.Reason);
|
||||
Assert.Single(handler.RequestUris);
|
||||
}
|
||||
|
||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||
{
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
return Assert.IsType<T>(result.Value);
|
||||
}
|
||||
|
||||
private static RegisterSessionRequest CreateRegistrationRequest(string idempotencyKey) => new()
|
||||
{
|
||||
IdempotencyKey = idempotencyKey,
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.0.0",
|
||||
DisplayName = "SDK host",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
|
||||
};
|
||||
|
||||
private static RegisterSessionResponse CreateRegistrationResponse() => new()
|
||||
{
|
||||
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000010")),
|
||||
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000011")),
|
||||
LeaseToken = "lease-token",
|
||||
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000012")),
|
||||
HostPresenceCapability = "presence-capability",
|
||||
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
LeaseRenewAfterSeconds = 15,
|
||||
HostPresenceRefreshAfterSeconds = 10,
|
||||
};
|
||||
|
||||
private static SessionListing CreateListing(string id) => new()
|
||||
{
|
||||
ListingId = new(Guid.Parse(id)),
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.0.0",
|
||||
DisplayName = "host",
|
||||
Visibility = ListingVisibility.Public,
|
||||
PublisherTrustMode = PublisherTrustMode.ManagedDedicated,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
|
||||
};
|
||||
|
||||
private static TrackingContent JsonContent<T>(T value) => new(
|
||||
JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options));
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode status, HttpContent content) => new(status)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
|
||||
private sealed class ScriptedHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||
|
||||
internal List<string> RequestBodies { get; } = [];
|
||||
internal List<Uri> RequestUris { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestUris.Add(request.RequestUri!);
|
||||
RequestBodies.Add(request.Content is null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken));
|
||||
return _responses.Count > 0
|
||||
? _responses.Dequeue()
|
||||
: throw new InvalidOperationException("No scripted response remains.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackingContent(byte[] bytes) : HttpContent
|
||||
{
|
||||
internal bool IsDisposed { get; private set; }
|
||||
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||
stream.WriteAsync(bytes).AsTask();
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = bytes.Length;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
IsDisposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingDelay(Action? onDelay = null) : IRendezvousDelay
|
||||
{
|
||||
internal List<TimeSpan> Delays { get; } = [];
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Delays.Add(delay);
|
||||
onDelay?.Invoke();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingDelay : IRendezvousDelay
|
||||
{
|
||||
internal TaskCompletionSource Started { get; } = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
Started.TrySetResult();
|
||||
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class RendezvousClientIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
|
||||
{
|
||||
await using ClientTestHost host = await ClientTestHost.StartAsync();
|
||||
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||
RendezvousSessionBrowserClient browser = new(host.HttpClient);
|
||||
List<PublishedSession> sessions = [];
|
||||
|
||||
for (int index = 0; index < 3; index++)
|
||||
{
|
||||
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||
CreateRegistration(index),
|
||||
host.PublisherCredential);
|
||||
PublishedSession session = AssertSuccess(registered);
|
||||
sessions.Add(session);
|
||||
Assert.True(host.Capabilities.TryFingerprint(
|
||||
session.HostPresenceCapability,
|
||||
out SecretFingerprint fingerprint));
|
||||
StoreResult<StoredListing> bound = host.Store.BindHostPresence(new(
|
||||
session.HostPresenceHandle,
|
||||
fingerprint,
|
||||
new(AddressFamilyKind.Ipv4, $"203.0.113.{80 + index}", 41_000 + index),
|
||||
null));
|
||||
Assert.Equal(StoreResultCode.Success, bound.Code);
|
||||
}
|
||||
|
||||
PublishedSession first = sessions[0];
|
||||
RendezvousClientResult<RenewLeaseResponse> renewed = await publisher.RenewAsync(
|
||||
first,
|
||||
host.PublisherCredential);
|
||||
Assert.True(renewed.IsSuccess, renewed.Message);
|
||||
Assert.Equal(renewed.Value!.ExpiresAt, first.ExpiresAt);
|
||||
|
||||
UpdateSessionRequest update = new()
|
||||
{
|
||||
BuildVersion = "2.0.0",
|
||||
DisplayName = "SDK host updated",
|
||||
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||
Metadata = new() { ["mode"] = "online-coop" },
|
||||
};
|
||||
RendezvousClientResult<bool> updated = await publisher.UpdateAsync(
|
||||
first,
|
||||
update,
|
||||
host.PublisherCredential);
|
||||
Assert.True(updated.IsSuccess, updated.Message);
|
||||
|
||||
BrowseSessionsRequest browseRequest = new()
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
PageSize = 1,
|
||||
ExcludeFull = true,
|
||||
};
|
||||
IReadOnlyList<SessionListing> listings = AssertSuccess(
|
||||
await browser.BrowseAllAsync(browseRequest));
|
||||
Assert.Equal(3, listings.Count);
|
||||
Assert.Equal("SDK host updated", listings.Single(item => item.ListingId == first.ListingId).DisplayName);
|
||||
|
||||
GetSessionResponse direct = AssertSuccess(await browser.GetAsync(
|
||||
first.ListingId,
|
||||
new("space-game"),
|
||||
new("production"),
|
||||
7));
|
||||
Assert.Equal("2.0.0", direct.Session.BuildVersion);
|
||||
|
||||
foreach (PublishedSession session in sessions)
|
||||
{
|
||||
RendezvousClientResult<bool> deregistered = await publisher.DeregisterAsync(
|
||||
session,
|
||||
host.PublisherCredential);
|
||||
Assert.True(deregistered.IsSuccess, deregistered.Message);
|
||||
Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(session.ListingId, false).Code);
|
||||
}
|
||||
}
|
||||
|
||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||
{
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
return Assert.IsAssignableFrom<T>(result.Value);
|
||||
}
|
||||
|
||||
private static RegisterSessionRequest CreateRegistration(int index) => new()
|
||||
{
|
||||
IdempotencyKey = $"sdk-integration-{index}",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.0.0",
|
||||
DisplayName = $"SDK host {index}",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||
Metadata = new() { ["mode"] = "online-coop" },
|
||||
};
|
||||
|
||||
private sealed class ClientTestHost : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _application;
|
||||
|
||||
private ClientTestHost(
|
||||
WebApplication application,
|
||||
HttpClient httpClient,
|
||||
InMemoryEphemeralRendezvousStore store,
|
||||
EphemeralCapabilityIssuer capabilities,
|
||||
string publisherCredential)
|
||||
{
|
||||
_application = application;
|
||||
HttpClient = httpClient;
|
||||
Store = store;
|
||||
Capabilities = capabilities;
|
||||
PublisherCredential = publisherCredential;
|
||||
}
|
||||
|
||||
internal HttpClient HttpClient { get; }
|
||||
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||
internal string PublisherCredential { get; }
|
||||
|
||||
internal static async Task<ClientTestHost> StartAsync()
|
||||
{
|
||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||
EphemeralCapabilityIssuer capabilities = new();
|
||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||
ProvisioningTestData.CreateOptions(),
|
||||
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||
clock.UtcNow);
|
||||
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||
ContractJson.Configure(options.SerializerOptions));
|
||||
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
builder.Services.AddSingleton(provisioning);
|
||||
builder.Services.AddSingleton(provisioning.Credentials);
|
||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||
builder.Services.AddSingleton<IWallClock>(clock);
|
||||
builder.Services.AddSingleton(capabilities);
|
||||
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
await app.StartAsync();
|
||||
IServer server = app.Services.GetRequiredService<IServer>();
|
||||
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||
return new(
|
||||
app,
|
||||
new HttpClient { BaseAddress = new Uri(address) },
|
||||
store,
|
||||
capabilities,
|
||||
credential);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
HttpClient.Dispose();
|
||||
await _application.StopAsync();
|
||||
await _application.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||
|
||||
public sealed class ContractLimitTests
|
||||
{
|
||||
[Fact]
|
||||
public void RequiredPlayerFacingTextRejectsEmptyOrWhitespaceValues()
|
||||
{
|
||||
Assert.False(ContractValidation.IsBuildVersionValid(string.Empty));
|
||||
Assert.False(ContractValidation.IsBuildVersionValid(" "));
|
||||
Assert.False(ContractValidation.IsDisplayNameValid(string.Empty));
|
||||
Assert.False(ContractValidation.IsDisplayNameValid(" "));
|
||||
}
|
||||
|
||||
[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,105 @@
|
||||
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) },
|
||||
{ "register-session-response.json", typeof(RegisterSessionResponse) },
|
||||
{ "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,108 @@
|
||||
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}",
|
||||
"/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));
|
||||
|
||||
JsonElement publisherBearer = root.GetProperty("components")
|
||||
.GetProperty("securitySchemes")
|
||||
.GetProperty("PublisherBearer");
|
||||
Assert.Equal("http", publisherBearer.GetProperty("type").GetString());
|
||||
Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString());
|
||||
(string Path, string Method)[] publisherOperations =
|
||||
[
|
||||
("/v1/sessions", "post"),
|
||||
("/v1/sessions/{listingId}", "put"),
|
||||
("/v1/sessions/{listingId}", "delete"),
|
||||
("/v1/sessions/{listingId}/renew", "post"),
|
||||
];
|
||||
foreach ((string operationPath, string method) in publisherOperations)
|
||||
{
|
||||
JsonElement security = root.GetProperty("paths")
|
||||
.GetProperty(operationPath)
|
||||
.GetProperty(method)
|
||||
.GetProperty("security");
|
||||
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
|
||||
}
|
||||
|
||||
JsonElement cancelParameters = root.GetProperty("paths")
|
||||
.GetProperty("/v1/join-attempts/{attemptId}")
|
||||
.GetProperty("delete")
|
||||
.GetProperty("parameters");
|
||||
JsonElement cancelCapability = Assert.Single(cancelParameters.EnumerateArray(), static parameter =>
|
||||
parameter.GetProperty("in").GetString() == "header"
|
||||
&& parameter.GetProperty("name").GetString()
|
||||
== "X-Rendezvous-Client-Punch-Capability");
|
||||
Assert.True(cancelCapability.GetProperty("required").GetBoolean());
|
||||
JsonElement hostPollParameters = root.GetProperty("paths")
|
||||
.GetProperty("/v1/sessions/{listingId}/join-attempts")
|
||||
.GetProperty("get")
|
||||
.GetProperty("parameters");
|
||||
JsonElement leaseToken = Assert.Single(hostPollParameters.EnumerateArray(), static parameter =>
|
||||
parameter.GetProperty("in").GetString() == "header"
|
||||
&& parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token");
|
||||
Assert.True(leaseToken.GetProperty("required").GetBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Reflection;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientPublicApiMatchesTheV1Snapshot()
|
||||
{
|
||||
string snapshot = CreateSnapshot(typeof(RendezvousPublisherClient).Assembly);
|
||||
string expected = ContractTestFiles.Read("client-public-api.txt");
|
||||
if (expected == "SNAPSHOT_PENDING"
|
||||
&& Environment.GetEnvironmentVariable("RENDEZVOUS_UPDATE_CONTRACT_SNAPSHOT") == "1")
|
||||
{
|
||||
string snapshotPath = Path.Combine(
|
||||
ContractTestFiles.Directory,
|
||||
"client-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.GetMethod?.IsPublic == true ? "get;" : string.Empty)}{(property.SetMethod?.IsPublic == true ? "set;" : string.Empty)}";
|
||||
lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}");
|
||||
}
|
||||
|
||||
foreach (EventInfo eventInfo in type.GetEvents(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||
.OrderBy(static eventInfo => eventInfo.Name, StringComparer.Ordinal))
|
||||
{
|
||||
lines.Add($" EVENT {FormatType(eventInfo.EventHandlerType!)} {eventInfo.Name}");
|
||||
}
|
||||
|
||||
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,204 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
|
||||
public sealed class JoinAttemptHttpEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ClientCreatesHostPollsAndCapabilityCancelsAnAttemptOverHttp()
|
||||
{
|
||||
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistration(),
|
||||
host.PublisherCredential));
|
||||
Assert.True(host.Capabilities.TryFingerprint(
|
||||
session.HostPresenceCapability,
|
||||
out SecretFingerprint presenceFingerprint));
|
||||
Assert.True(host.Store.BindHostPresence(new(
|
||||
session.HostPresenceHandle,
|
||||
presenceFingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||
null)).Succeeded);
|
||||
CreateJoinAttemptRequest request = new()
|
||||
{
|
||||
IdempotencyKey = "http-join-1",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ListingId = session.ListingId,
|
||||
ProtocolVersion = 7,
|
||||
};
|
||||
|
||||
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
|
||||
"v1/join-attempts",
|
||||
request,
|
||||
ContractJson.Options);
|
||||
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
|
||||
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
|
||||
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
|
||||
|
||||
using HttpRequestMessage pollRequest = new(
|
||||
HttpMethod.Get,
|
||||
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||
pollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||
using HttpResponseMessage pollResponse = await host.HttpClient.SendAsync(pollRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, pollResponse.StatusCode);
|
||||
BrowseHostJoinAttemptsResponse polled = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||
await pollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||
HostJoinAttempt hostAttempt = Assert.Single(polled.Items);
|
||||
Assert.Equal(created.AttemptId, hostAttempt.AttemptId);
|
||||
Assert.NotEqual(created.ClientPunchCapability, hostAttempt.HostPunchCapability);
|
||||
|
||||
using HttpResponseMessage missingCapability = await host.HttpClient.DeleteAsync(
|
||||
$"v1/join-attempts/{created.AttemptId}");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, missingCapability.StatusCode);
|
||||
ApiError missingCapabilityError = Assert.IsType<ApiError>(
|
||||
await missingCapability.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, missingCapabilityError.Code);
|
||||
|
||||
using HttpRequestMessage unauthorizedCancel = new(
|
||||
HttpMethod.Delete,
|
||||
$"v1/join-attempts/{created.AttemptId}");
|
||||
unauthorizedCancel.Headers.Add(
|
||||
"X-Rendezvous-Client-Punch-Capability",
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedCancel);
|
||||
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
|
||||
|
||||
using HttpRequestMessage cancelRequest = new(
|
||||
HttpMethod.Delete,
|
||||
$"v1/join-attempts/{created.AttemptId}");
|
||||
cancelRequest.Headers.Add(
|
||||
"X-Rendezvous-Client-Punch-Capability",
|
||||
created.ClientPunchCapability);
|
||||
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
|
||||
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
|
||||
|
||||
using HttpRequestMessage emptyPollRequest = new(
|
||||
HttpMethod.Get,
|
||||
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest);
|
||||
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||
Assert.Empty(empty.Items);
|
||||
}
|
||||
|
||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||
{
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
return Assert.IsAssignableFrom<T>(result.Value);
|
||||
}
|
||||
|
||||
private static RegisterSessionRequest CreateRegistration() => new()
|
||||
{
|
||||
IdempotencyKey = "join-http-host",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.0.0",
|
||||
DisplayName = "Join HTTP host",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
|
||||
Metadata = new() { ["mode"] = "online-coop" },
|
||||
};
|
||||
|
||||
private sealed class JoinHttpTestHost : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _application;
|
||||
|
||||
private JoinHttpTestHost(
|
||||
WebApplication application,
|
||||
HttpClient httpClient,
|
||||
InMemoryEphemeralRendezvousStore store,
|
||||
EphemeralCapabilityIssuer capabilities,
|
||||
string publisherCredential)
|
||||
{
|
||||
_application = application;
|
||||
HttpClient = httpClient;
|
||||
Store = store;
|
||||
Capabilities = capabilities;
|
||||
PublisherCredential = publisherCredential;
|
||||
}
|
||||
|
||||
internal HttpClient HttpClient { get; }
|
||||
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||
internal string PublisherCredential { get; }
|
||||
|
||||
internal static async Task<JoinHttpTestHost> StartAsync()
|
||||
{
|
||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||
EphemeralCapabilityIssuer capabilities = new();
|
||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||
ProvisioningTestData.CreateOptions(),
|
||||
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||
clock.UtcNow);
|
||||
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||
ContractJson.Configure(options.SerializerOptions));
|
||||
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
builder.Services.AddSingleton(provisioning);
|
||||
builder.Services.AddSingleton(provisioning.Policies);
|
||||
builder.Services.AddSingleton(provisioning.Credentials);
|
||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||
builder.Services.AddSingleton<IWallClock>(clock);
|
||||
builder.Services.AddSingleton(capabilities);
|
||||
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||
builder.Services.AddSingleton<JoinAttemptService>();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
await app.StartAsync();
|
||||
IServer server = app.Services.GetRequiredService<IServer>();
|
||||
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||
return new(
|
||||
app,
|
||||
new HttpClient { BaseAddress = new Uri(address) },
|
||||
store,
|
||||
capabilities,
|
||||
credential);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
HttpClient.Dispose();
|
||||
await _application.StopAsync();
|
||||
await _application.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
|
||||
public sealed class JoinAttemptServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateIsIdempotentAndScopesDistinctRoleCredentials()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "stable-join-key");
|
||||
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> first = fixture.Service.Create(
|
||||
fixture.ClientSubject,
|
||||
request);
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> replay = fixture.Service.Create(
|
||||
fixture.ClientSubject,
|
||||
request);
|
||||
|
||||
Assert.True(first.Succeeded);
|
||||
Assert.True(replay.Succeeded);
|
||||
Assert.Equal(first.Value!.AttemptId, replay.Value!.AttemptId);
|
||||
Assert.Equal(first.Value.MediationHandle, replay.Value.MediationHandle);
|
||||
Assert.Equal(first.Value.ClientPunchCapability, replay.Value.ClientPunchCapability);
|
||||
Assert.True(ContractValidation.IsCapabilityValid(first.Value.ClientPunchCapability));
|
||||
Assert.InRange(
|
||||
first.Value.ClientPunchCapability.Length,
|
||||
1,
|
||||
ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||
|
||||
HostJoinAttempt host = Assert.Single(fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
10,
|
||||
null).Value!.Items);
|
||||
Assert.NotEqual(host.HostPunchCapability, first.Value.ClientPunchCapability);
|
||||
Assert.DoesNotContain(first.Value.ClientPunchCapability, fixture.Sessions.Store.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameIdempotencyKeyWithDifferentRequestConflicts()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "reused-key");
|
||||
Assert.True(fixture.Service.Create(fixture.ClientSubject, request).Succeeded);
|
||||
|
||||
request.ListingId = other.ListingId;
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> conflict = fixture.Service.Create(
|
||||
fixture.ClientSubject,
|
||||
request);
|
||||
|
||||
Assert.Equal(RendezvousErrorCode.Conflict, conflict.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreationRejectsStaleIncompatibleAndCrossTenantListings()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.NotFound,
|
||||
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
|
||||
|
||||
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptRequest incompatible = fixture.Request(active.ListingId);
|
||||
incompatible.ProtocolVersion = 8;
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.IncompatibleProtocol,
|
||||
fixture.Service.Create(fixture.ClientSubject, incompatible).Error);
|
||||
|
||||
CreateJoinAttemptRequest otherTenant = fixture.Request(active.ListingId);
|
||||
otherTenant.GameId = new("other-game");
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.NotFound,
|
||||
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
fixture.Create(registration.ListingId);
|
||||
fixture.Create(registration.ListingId);
|
||||
fixture.Create(registration.ListingId);
|
||||
|
||||
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> first = fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
1,
|
||||
null);
|
||||
Assert.True(first.Succeeded);
|
||||
Assert.Single(first.Value!.Items);
|
||||
Assert.NotNull(first.Value.NextCursor);
|
||||
|
||||
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> second = fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
1,
|
||||
first.Value.NextCursor);
|
||||
Assert.True(second.Succeeded);
|
||||
Assert.NotEqual(first.Value.Items[0].AttemptId, second.Value!.Items[0].AttemptId);
|
||||
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.NotFound,
|
||||
fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
1,
|
||||
null).Error);
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.InvalidRequest,
|
||||
fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
1,
|
||||
first.Value.NextCursor + "x").Error);
|
||||
|
||||
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.InvalidRequest,
|
||||
fixture.Service.BrowseForHost(
|
||||
other.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
other.LeaseToken,
|
||||
1,
|
||||
first.Value.NextCursor).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationRequiresTheAttemptsClientCapabilityAndRevokesState()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.NotFound,
|
||||
fixture.Service.Cancel(
|
||||
created.AttemptId,
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Error);
|
||||
Assert.True(fixture.Service.Cancel(
|
||||
created.AttemptId,
|
||||
created.ClientPunchCapability).Succeeded);
|
||||
Assert.Empty(fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
10,
|
||||
null).Value!.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoleAndAttemptCapabilitiesCannotCrossWireConcurrentAttempts()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
||||
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
||||
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
||||
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||
second.ClientPunchCapability,
|
||||
out SecretFingerprint secondClientFingerprint));
|
||||
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||
first.ClientPunchCapability,
|
||||
out SecretFingerprint firstClientFingerprint));
|
||||
|
||||
Assert.Equal(
|
||||
StoreResultCode.NotFound,
|
||||
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
||||
firstStored.MediationHandle,
|
||||
AttemptPeerRole.Client,
|
||||
secondClientFingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "198.51.100.20", 42_000),
|
||||
null)).Code);
|
||||
Assert.Equal(
|
||||
StoreResultCode.NotFound,
|
||||
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
||||
firstStored.MediationHandle,
|
||||
AttemptPeerRole.Host,
|
||||
firstClientFingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
|
||||
null)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectionTicketIsDistinctExpiringAndAtomicallySingleUse()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||
JoinAttemptServiceResult<ConnectionTicketGrant> issued = fixture.Service.IssueConnectionTicket(
|
||||
introduction.Attempt);
|
||||
Assert.True(issued.Succeeded);
|
||||
Assert.True(ContractValidation.IsConnectionTicketValid(issued.Value!.Ticket));
|
||||
Assert.NotEqual(created.ClientPunchCapability, issued.Value.Ticket);
|
||||
Assert.DoesNotContain(issued.Value.Ticket, issued.Value.ToString(), StringComparison.Ordinal);
|
||||
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||
issued.Value.Ticket,
|
||||
out SecretFingerprint ticketFingerprint));
|
||||
ConsumeConnectionTicketCommand command = new(created.AttemptId, ticketFingerprint);
|
||||
using ManualResetEventSlim start = new(false);
|
||||
|
||||
Task<StoreResult<bool>> left = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
||||
});
|
||||
Task<StoreResult<bool>> right = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
||||
});
|
||||
start.Set();
|
||||
StoreResult<bool>[] results = await Task.WhenAll(left, right);
|
||||
|
||||
Assert.Single(results, static result => result.Succeeded);
|
||||
Assert.Single(results, static result => result.Code == StoreResultCode.ReplayRejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TicketRejectsAlteredCrossAttemptPreIntroductionAndExpiry()
|
||||
{
|
||||
EphemeralStoreOptions options = new()
|
||||
{
|
||||
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
using JoinAttemptFixture fixture = new(options);
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
||||
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
||||
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
||||
StoredJoinAttempt secondStored = fixture.GetAttempt(registration, second.AttemptId);
|
||||
|
||||
Assert.Equal(
|
||||
StoreResultCode.Conflict,
|
||||
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||
first.AttemptId,
|
||||
firstStored.ConnectionTicketFingerprint)).Code);
|
||||
Assert.Equal(
|
||||
StoreResultCode.NotFound,
|
||||
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||
second.AttemptId,
|
||||
firstStored.ConnectionTicketFingerprint)).Code);
|
||||
|
||||
fixture.Introduce(registration, first);
|
||||
fixture.Sessions.Clock.Advance(options.ConnectionTicketLifetime);
|
||||
Assert.Equal(
|
||||
StoreResultCode.Expired,
|
||||
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||
first.AttemptId,
|
||||
firstStored.ConnectionTicketFingerprint)).Code);
|
||||
Assert.False(secondStored.ConnectionTicketConsumed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TicketWindowBeginsAtIntroductionAndNeverOutlivesTheAttempt()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(15));
|
||||
|
||||
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||
ConnectionTicketGrant ticket = Assert.IsType<ConnectionTicketGrant>(
|
||||
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
|
||||
|
||||
Assert.Equal(created.ExpiresAt, ticket.ExpiresAt);
|
||||
Assert.Equal(TimeSpan.FromSeconds(15), ticket.ExpiresAt - fixture.Sessions.Clock.UtcNow);
|
||||
Assert.DoesNotContain(
|
||||
introduction.Attempt.CapabilityDerivationSalt,
|
||||
introduction.Attempt.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
using FinalFactory.Rendezvous.Tests.Sessions;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
|
||||
internal sealed class JoinAttemptFixture : IDisposable
|
||||
{
|
||||
private int _sequence;
|
||||
|
||||
public JoinAttemptFixture(EphemeralStoreOptions? options = null)
|
||||
{
|
||||
Sessions = new(options);
|
||||
Cursors = new();
|
||||
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
|
||||
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
|
||||
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
|
||||
}
|
||||
|
||||
public SessionLeaseFixture Sessions { get; }
|
||||
public JoinAttemptCursorCodec Cursors { get; }
|
||||
public JoinAttemptService Service { get; }
|
||||
public string ClientSubject { get; }
|
||||
|
||||
public (RegisterSessionResponse Registration, StoredListing Listing) CreateHost(bool bindPresence = true)
|
||||
{
|
||||
RegisterSessionResponse registration = Sessions.Register();
|
||||
if (bindPresence)
|
||||
{
|
||||
Assert.True(Sessions.BindPresence(registration).Succeeded);
|
||||
}
|
||||
|
||||
StoredListing listing = Sessions.Store.GetListing(registration.ListingId, false).Value!;
|
||||
return (registration, listing);
|
||||
}
|
||||
|
||||
public CreateJoinAttemptRequest Request(
|
||||
SessionListingId listingId,
|
||||
string? idempotencyKey = null) => new()
|
||||
{
|
||||
IdempotencyKey = idempotencyKey ?? $"join-{Interlocked.Increment(ref _sequence)}",
|
||||
GameId = Sessions.Scope.GameId,
|
||||
EnvironmentId = Sessions.Scope.EnvironmentId,
|
||||
ListingId = listingId,
|
||||
ProtocolVersion = 7,
|
||||
};
|
||||
|
||||
public CreateJoinAttemptResponse Create(
|
||||
SessionListingId listingId,
|
||||
string? idempotencyKey = null)
|
||||
{
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = Service.Create(
|
||||
ClientSubject,
|
||||
Request(listingId, idempotencyKey));
|
||||
Assert.True(result.Succeeded);
|
||||
return Assert.IsType<CreateJoinAttemptResponse>(result.Value);
|
||||
}
|
||||
|
||||
public StoredJoinAttempt GetAttempt(
|
||||
RegisterSessionResponse registration,
|
||||
JoinAttemptId attemptId)
|
||||
{
|
||||
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||
registration.LeaseToken,
|
||||
out SecretFingerprint leaseFingerprint));
|
||||
IReadOnlyList<StoredJoinAttempt> attempts = Sessions.Store.BrowseHostJoinAttempts(new(
|
||||
registration.ListingId,
|
||||
leaseFingerprint,
|
||||
ContractLimits.BrowserPageMaxItems)).Value!;
|
||||
return attempts.Single(attempt => attempt.AttemptId == attemptId);
|
||||
}
|
||||
|
||||
public IntroductionEndpoints Introduce(
|
||||
RegisterSessionResponse registration,
|
||||
CreateJoinAttemptResponse created)
|
||||
{
|
||||
StoredJoinAttempt attempt = GetAttempt(registration, created.AttemptId);
|
||||
HostJoinAttempt host = Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||
host.HostPunchCapability,
|
||||
out SecretFingerprint hostFingerprint));
|
||||
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||
created.ClientPunchCapability,
|
||||
out SecretFingerprint clientFingerprint));
|
||||
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
|
||||
attempt.MediationHandle,
|
||||
AttemptPeerRole.Host,
|
||||
hostFingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
|
||||
null)).Succeeded);
|
||||
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
|
||||
attempt.MediationHandle,
|
||||
AttemptPeerRole.Client,
|
||||
clientFingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "198.51.100.40", 42_000),
|
||||
null)).Succeeded);
|
||||
StoreResult<IntroductionEndpoints> introduced = Sessions.Store.ConsumeIntroduction(
|
||||
attempt.MediationHandle);
|
||||
Assert.True(introduced.Succeeded);
|
||||
return introduced.Value!;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cursors.Dispose();
|
||||
Sessions.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -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,234 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
|
||||
public sealed class PrincipalCredentialTests
|
||||
{
|
||||
[Fact]
|
||||
public void Base64UrlDecoderRejectsNonCanonicalTrailingBits()
|
||||
{
|
||||
Assert.True(Base64Url.TryDecode("AA", out byte[] canonical));
|
||||
Assert.Equal(new byte[] { 0 }, canonical);
|
||||
Assert.False(Base64Url.TryDecode("AB", out byte[] nonCanonical));
|
||||
Assert.Empty(nonCanonical);
|
||||
}
|
||||
|
||||
[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()]));
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using FinalFactory.Rendezvous.Tests.Sessions;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -7,6 +12,39 @@ namespace FinalFactory.Rendezvous.Tests.Server;
|
||||
|
||||
public sealed class UdpMediatorServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuthenticatedHostDatagramGatesVisibilityUsingObservedGameplaySocket()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions { ListenAddress = "127.0.0.1", Port = 0 }),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
fixture.Store,
|
||||
fixture.Capabilities);
|
||||
PresenceDatagram presence = new()
|
||||
{
|
||||
MessageType = UdpPresenceMessageType.HostPresence,
|
||||
MediationHandle = registration.HostPresenceHandle,
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
LocalAddress = "192.168.1.50",
|
||||
LocalPort = 40_000,
|
||||
Capability = registration.HostPresenceCapability,
|
||||
};
|
||||
IPEndPoint observedGameplaySocket = new(IPAddress.Parse("203.0.113.77"), 51_234);
|
||||
presence.Capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
Assert.Equal(
|
||||
UdpPresenceProcessingResult.HostPresenceRejected,
|
||||
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
||||
Assert.Empty(fixture.Browse());
|
||||
|
||||
presence.Capability = registration.HostPresenceCapability;
|
||||
Assert.Equal(
|
||||
UdpPresenceProcessingResult.HostPresenceAccepted,
|
||||
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
||||
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
|
||||
{
|
||||
@@ -16,9 +54,14 @@ public sealed class UdpMediatorServiceTests
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
};
|
||||
ManualRendezvousClock clock = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock);
|
||||
using EphemeralCapabilityIssuer capabilities = new();
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(options),
|
||||
NullLogger<UdpMediatorService>.Instance);
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
store,
|
||||
capabilities);
|
||||
|
||||
await service.StartAsync(timeout.Token);
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||
|
||||
public sealed class SessionHttpEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AuthenticatedHttpLifecycleReturnsStableContractsAndStatuses()
|
||||
{
|
||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||
EphemeralCapabilityIssuer capabilities = new();
|
||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||
ProvisioningTestData.CreateOptions(),
|
||||
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||
clock.UtcNow);
|
||||
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||
string publisherCredential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||
ContractJson.Configure(options.SerializerOptions));
|
||||
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
builder.Services.AddSingleton(provisioning);
|
||||
builder.Services.AddSingleton(provisioning.Credentials);
|
||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||
builder.Services.AddSingleton<IWallClock>(clock);
|
||||
builder.Services.AddSingleton(capabilities);
|
||||
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
await using WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
await app.StartAsync();
|
||||
IServer server = app.Services.GetRequiredService<IServer>();
|
||||
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||
using HttpClient client = new() { BaseAddress = new Uri(address) };
|
||||
RegisterSessionRequest registration = new()
|
||||
{
|
||||
IdempotencyKey = "http-register-1",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.4.2",
|
||||
DisplayName = "HTTP host",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["mode"] = "co-op",
|
||||
},
|
||||
};
|
||||
|
||||
HttpResponseMessage unauthenticated = await client.PostAsJsonAsync(
|
||||
"/v1/sessions",
|
||||
registration,
|
||||
ContractJson.Options);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
|
||||
Assert.Equal("Bearer", Assert.Single(unauthenticated.Headers.WwwAuthenticate).Scheme);
|
||||
ApiError? authenticationError = await unauthenticated.Content.ReadFromJsonAsync<ApiError>(
|
||||
ContractJson.Options);
|
||||
Assert.Equal(RendezvousErrorCode.AuthenticationRequired, authenticationError!.Code);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
publisherCredential);
|
||||
string invalidJson = JsonSerializer.Serialize(registration, ContractJson.Options)
|
||||
.Replace("\"public\"", "\"futureVisibility\"", StringComparison.Ordinal);
|
||||
HttpResponseMessage invalid = await client.PostAsync(
|
||||
"/v1/sessions",
|
||||
new StringContent(invalidJson, Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||
ApiError? invalidError = await invalid.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options);
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, invalidError!.Code);
|
||||
|
||||
HttpResponseMessage created = await client.PostAsJsonAsync(
|
||||
"/v1/sessions",
|
||||
registration,
|
||||
ContractJson.Options);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
RegisterSessionResponse? session = await created.Content.ReadFromJsonAsync<RegisterSessionResponse>(
|
||||
ContractJson.Options);
|
||||
Assert.NotNull(session);
|
||||
Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString);
|
||||
Assert.True(capabilities.TryFingerprint(
|
||||
session.HostPresenceCapability,
|
||||
out SecretFingerprint presenceFingerprint));
|
||||
store.BindHostPresence(new(
|
||||
session.HostPresenceHandle,
|
||||
presenceFingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||
null));
|
||||
|
||||
BrowseSessionsResponse? browser = await client.GetFromJsonAsync<BrowseSessionsResponse>(
|
||||
"/v1/sessions?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7®ionId=eu-central&pageSize=10&excludeFull=true",
|
||||
ContractJson.Options);
|
||||
Assert.Equal(session.ListingId, Assert.Single(browser!.Items).ListingId);
|
||||
GetSessionResponse? direct = await client.GetFromJsonAsync<GetSessionResponse>(
|
||||
$"/v1/sessions/{session.ListingId}?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7",
|
||||
ContractJson.Options);
|
||||
Assert.Equal(session.ListingId, direct!.Session.ListingId);
|
||||
|
||||
HttpResponseMessage renewed = await client.PostAsJsonAsync(
|
||||
$"/v1/sessions/{session.ListingId}/renew",
|
||||
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
|
||||
ContractJson.Options);
|
||||
Assert.Equal(HttpStatusCode.OK, renewed.StatusCode);
|
||||
Assert.NotNull(await renewed.Content.ReadFromJsonAsync<RenewLeaseResponse>(ContractJson.Options));
|
||||
|
||||
HttpResponseMessage updated = await client.PutAsJsonAsync(
|
||||
$"/v1/sessions/{session.ListingId}",
|
||||
new UpdateSessionRequest
|
||||
{
|
||||
LeaseToken = session.LeaseToken,
|
||||
BuildVersion = "1.4.3",
|
||||
DisplayName = "HTTP host updated",
|
||||
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
|
||||
},
|
||||
ContractJson.Options);
|
||||
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
|
||||
|
||||
using HttpRequestMessage deleteRequest = new(
|
||||
HttpMethod.Delete,
|
||||
$"/v1/sessions/{session.ListingId}")
|
||||
{
|
||||
Content = JsonContent.Create(
|
||||
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
|
||||
options: ContractJson.Options),
|
||||
};
|
||||
HttpResponseMessage deleted = await client.SendAsync(deleteRequest);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||
Assert.Equal(StoreResultCode.NotFound, store.GetListing(session.ListingId, false).Code);
|
||||
|
||||
await app.StopAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||
|
||||
public sealed class SessionLeaseServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegistrationReturnsOpaqueCredentialsButRemainsHiddenUntilPresence()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
|
||||
RegisterSessionResponse response = fixture.Register();
|
||||
|
||||
Assert.Equal(30, response.LeaseRenewAfterSeconds);
|
||||
Assert.Equal(10, response.HostPresenceRefreshAfterSeconds);
|
||||
Assert.Equal(43, response.LeaseToken.Length);
|
||||
Assert.Equal(43, response.HostPresenceCapability.Length);
|
||||
Assert.Empty(fixture.Browse());
|
||||
string json = JsonSerializer.Serialize(response, ContractJson.Options);
|
||||
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("fingerprint", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("store", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactRegistrationRetryReproducesIdsAndCapabilitiesWithoutRetainingPlaintext()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionRequest request = fixture.Request("same-key");
|
||||
|
||||
RegisterSessionResponse first = fixture.Register(request);
|
||||
RegisterSessionRequest reordered = fixture.Request("same-key");
|
||||
reordered.Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["map"] = "europa",
|
||||
["mode"] = "co-op",
|
||||
};
|
||||
RegisterSessionResponse duplicate = fixture.Register(reordered);
|
||||
RegisterSessionRequest changedRequest = fixture.Request("same-key");
|
||||
changedRequest.DisplayName = "Changed";
|
||||
SessionServiceResult<RegisterSessionResponse> changed = fixture.Service.Register(
|
||||
fixture.Principal,
|
||||
changedRequest);
|
||||
|
||||
Assert.Equal(first.ListingId, duplicate.ListingId);
|
||||
Assert.Equal(first.LeaseId, duplicate.LeaseId);
|
||||
Assert.Equal(first.LeaseToken, duplicate.LeaseToken);
|
||||
Assert.Equal(first.HostPresenceHandle, duplicate.HostPresenceHandle);
|
||||
Assert.Equal(first.HostPresenceCapability, duplicate.HostPresenceCapability);
|
||||
Assert.Equal(RendezvousErrorCode.Conflict, changed.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReRegistrationAfterIdempotencyExpiryRotatesIdsAndCapabilities()
|
||||
{
|
||||
EphemeralStoreOptions options = new()
|
||||
{
|
||||
LeaseLifetime = TimeSpan.FromSeconds(5),
|
||||
JoinAttemptLifetime = TimeSpan.FromSeconds(5),
|
||||
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
|
||||
IdempotencyLifetime = TimeSpan.FromSeconds(6),
|
||||
};
|
||||
using SessionLeaseFixture fixture = new(options);
|
||||
RegisterSessionRequest request = fixture.Request("reused-after-expiry");
|
||||
RegisterSessionResponse first = fixture.Register(request);
|
||||
|
||||
fixture.Clock.Advance(options.IdempotencyLifetime);
|
||||
RegisterSessionResponse second = fixture.Register(request);
|
||||
|
||||
Assert.NotEqual(first.ListingId, second.ListingId);
|
||||
Assert.NotEqual(first.LeaseId, second.LeaseId);
|
||||
Assert.NotEqual(first.LeaseToken, second.LeaseToken);
|
||||
Assert.NotEqual(first.HostPresenceCapability, second.HostPresenceCapability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresenceTransitionsAwaitingToListedToStaleAndBackWithoutChangingIdentity()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
|
||||
Assert.Empty(fixture.Browse());
|
||||
Assert.True(fixture.BindPresence(registration).Succeeded);
|
||||
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||
|
||||
fixture.Clock.Advance(fixture.StoreOptions.PresenceLifetime);
|
||||
Assert.Empty(fixture.Browse());
|
||||
Assert.True(fixture.BindPresence(registration).Succeeded);
|
||||
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenewUpdateAndDeleteMaintainCanonicalIdentityAndAdvisoryCapacity()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||
|
||||
SessionServiceResult<RenewLeaseResponse> renewed = fixture.Service.Renew(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken });
|
||||
SessionServiceResult<bool> updated = fixture.Service.Update(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new()
|
||||
{
|
||||
LeaseToken = registration.LeaseToken,
|
||||
BuildVersion = "1.4.3",
|
||||
DisplayName = "Europa Updated",
|
||||
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["mode"] = "co-op",
|
||||
["map"] = "europa",
|
||||
},
|
||||
});
|
||||
StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!;
|
||||
|
||||
Assert.True(renewed.Succeeded);
|
||||
Assert.Equal(fixture.Clock.UtcNow.Add(fixture.StoreOptions.LeaseLifetime), renewed.Value!.ExpiresAt);
|
||||
Assert.True(updated.Succeeded);
|
||||
Assert.Equal(registration.ListingId, stored.Definition.ListingId);
|
||||
Assert.Equal(fixture.Scope, stored.Definition.Scope);
|
||||
Assert.Equal(8, stored.Definition.CurrentPlayers);
|
||||
Assert.Equal(8, stored.Definition.MaximumPlayers);
|
||||
Assert.True(fixture.Service.Delete(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken }).Succeeded);
|
||||
Assert.True(fixture.Service.Delete(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken }).Succeeded);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnotherPublisherCannotRenewUpdateOrDeleteListing()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
DedicatedPublisherPrincipal other = fixture.Publisher("publisher-2");
|
||||
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew(
|
||||
other,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken }).Error);
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Update(
|
||||
other,
|
||||
registration.ListingId,
|
||||
new()
|
||||
{
|
||||
LeaseToken = registration.LeaseToken,
|
||||
BuildVersion = "1.4.3",
|
||||
DisplayName = "Hijacked",
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 2 },
|
||||
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
|
||||
}).Error);
|
||||
Assert.True(fixture.Service.Delete(
|
||||
other,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken }).Succeeded);
|
||||
Assert.True(fixture.Store.GetListing(registration.ListingId, false).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentRenewDeleteCannotResurrectListing()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
using ManualResetEventSlim start = new(false);
|
||||
Task<SessionServiceResult<RenewLeaseResponse>> renew = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return fixture.Service.Renew(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken });
|
||||
});
|
||||
Task<SessionServiceResult<bool>> delete = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return fixture.Service.Delete(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken });
|
||||
});
|
||||
|
||||
start.Set();
|
||||
await Task.WhenAll(renew, delete);
|
||||
SessionServiceResult<RenewLeaseResponse> renewResult = await renew;
|
||||
SessionServiceResult<bool> deleteResult = await delete;
|
||||
|
||||
Assert.True(deleteResult.Succeeded);
|
||||
Assert.Contains(renewResult.Error, new[]
|
||||
{
|
||||
RendezvousErrorCode.None,
|
||||
RendezvousErrorCode.NotFound,
|
||||
RendezvousErrorCode.Conflict,
|
||||
});
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbandonedRegistrationExpiresAndFreesBoundedCapacity()
|
||||
{
|
||||
EphemeralStoreOptions options = new()
|
||||
{
|
||||
MaxListings = 1,
|
||||
LeaseLifetime = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
using SessionLeaseFixture fixture = new(options);
|
||||
fixture.Register(fixture.Request("first"));
|
||||
Assert.Equal(RendezvousErrorCode.CapacityExceeded, fixture.Service.Register(
|
||||
fixture.Principal,
|
||||
fixture.Request("second")).Error);
|
||||
|
||||
fixture.Clock.Advance(options.LeaseLifetime);
|
||||
|
||||
Assert.True(fixture.Service.Register(
|
||||
fixture.Principal,
|
||||
fixture.Request("second")).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaseExpiryRemovesMutationAndPresencePaths()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
fixture.BindPresence(registration);
|
||||
|
||||
fixture.Clock.Advance(fixture.StoreOptions.LeaseLifetime);
|
||||
|
||||
Assert.Empty(fixture.Browse());
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken }).Error);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.BindPresence(registration).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LossOfAtomicStateFailsLeaseMutationClosed()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
fixture.Store.MarkUnavailable();
|
||||
|
||||
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, fixture.Service.Renew(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken }).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidPolicyBoundInputsReturnStableTypedErrors()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionRequest capacity = fixture.Request("bad-capacity");
|
||||
capacity.Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 1 };
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||
fixture.Principal,
|
||||
capacity).Error);
|
||||
RegisterSessionRequest protocol = fixture.Request("bad-protocol");
|
||||
protocol.ProtocolVersion = 8;
|
||||
Assert.Equal(RendezvousErrorCode.IncompatibleProtocol, fixture.Service.Register(
|
||||
fixture.Principal,
|
||||
protocol).Error);
|
||||
RegisterSessionRequest region = fixture.Request("bad-region");
|
||||
region.RegionId = new("us-east");
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||
fixture.Principal,
|
||||
region).Error);
|
||||
RegisterSessionRequest visibility = fixture.Request("bad-visibility");
|
||||
visibility.Visibility = (ListingVisibility)99;
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||
fixture.Principal,
|
||||
visibility).Error);
|
||||
RegisterSessionRequest metadata = fixture.Request("bad-metadata");
|
||||
metadata.Metadata = new Dictionary<string, string> { ["unknown"] = "value" };
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||
fixture.Principal,
|
||||
metadata).Error);
|
||||
RegisterSessionRequest build = fixture.Request("bad-build");
|
||||
build.BuildVersion = " ";
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||
fixture.Principal,
|
||||
build).Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||
|
||||
internal sealed class SessionLeaseFixture : IDisposable
|
||||
{
|
||||
private int _sequence;
|
||||
|
||||
public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null)
|
||||
{
|
||||
StoreOptions = storeOptions ?? new EphemeralStoreOptions();
|
||||
Clock = new();
|
||||
Store = new(StoreOptions, Clock, Clock);
|
||||
Capabilities = new();
|
||||
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
|
||||
Service = new(
|
||||
new PublisherAuthorizationService(policies),
|
||||
Store,
|
||||
Capabilities,
|
||||
SessionLeaseTiming.From(StoreOptions),
|
||||
Clock);
|
||||
Principal = Publisher("publisher-1");
|
||||
}
|
||||
|
||||
public EphemeralStoreOptions StoreOptions { get; }
|
||||
public ManualRendezvousClock Clock { get; }
|
||||
public InMemoryEphemeralRendezvousStore Store { get; }
|
||||
public EphemeralCapabilityIssuer Capabilities { get; }
|
||||
public SessionLeaseService Service { get; }
|
||||
public DedicatedPublisherPrincipal Principal { get; }
|
||||
public TenantScope Scope { get; } = new(new("space-game"), new("production"));
|
||||
|
||||
public DedicatedPublisherPrincipal Publisher(string subject) => new(
|
||||
subject,
|
||||
Clock.UtcNow.AddMinutes(10),
|
||||
Scope.GameId,
|
||||
Scope.EnvironmentId,
|
||||
new HashSet<RegionId> { new("eu-central") });
|
||||
|
||||
public RegisterSessionRequest Request(string? idempotencyKey = null) => new()
|
||||
{
|
||||
IdempotencyKey = idempotencyKey ?? $"register-{Interlocked.Increment(ref _sequence)}",
|
||||
GameId = Scope.GameId,
|
||||
EnvironmentId = Scope.EnvironmentId,
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.4.2",
|
||||
DisplayName = "Europa Relay",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["mode"] = "co-op",
|
||||
["map"] = "europa",
|
||||
},
|
||||
};
|
||||
|
||||
public RegisterSessionResponse Register(
|
||||
RegisterSessionRequest? request = null,
|
||||
DedicatedPublisherPrincipal? principal = null)
|
||||
{
|
||||
SessionServiceResult<RegisterSessionResponse> result = Service.Register(
|
||||
principal ?? Principal,
|
||||
request ?? Request());
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.NotNull(result.Value);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
public StoreResult<StoredListing> BindPresence(RegisterSessionResponse registration)
|
||||
{
|
||||
Assert.True(Capabilities.TryFingerprint(
|
||||
registration.HostPresenceCapability,
|
||||
out SecretFingerprint fingerprint));
|
||||
return Store.BindHostPresence(new(
|
||||
registration.HostPresenceHandle,
|
||||
fingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "203.0.113.50", 40_000),
|
||||
new ObservedEndpoint(AddressFamilyKind.Ipv4, "192.168.1.50", 40_000)));
|
||||
}
|
||||
|
||||
public IReadOnlyList<StoredListing> Browse() => Store.BrowseVisibleListings(new(
|
||||
Scope,
|
||||
7,
|
||||
new RegionId("eu-central"))).Value!;
|
||||
|
||||
public void Dispose() => Capabilities.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
internal sealed class ManualRendezvousClock : IWallClock, IMonotonicClock
|
||||
{
|
||||
public ManualRendezvousClock(DateTimeOffset? utcNow = null) =>
|
||||
UtcNow = utcNow ?? new DateTimeOffset(2026, 7, 16, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DateTimeOffset UtcNow { get; private set; }
|
||||
public TimeSpan Elapsed { get; private set; }
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
Elapsed += duration;
|
||||
UtcNow += duration;
|
||||
}
|
||||
|
||||
public void MoveWall(TimeSpan duration) => UtcNow += duration;
|
||||
}
|
||||
|
||||
internal sealed class EphemeralStateFixture
|
||||
{
|
||||
private int _sequence;
|
||||
|
||||
public EphemeralStateFixture(EphemeralStoreOptions? options = null)
|
||||
{
|
||||
Clock = new();
|
||||
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock);
|
||||
}
|
||||
|
||||
public ManualRendezvousClock Clock { get; }
|
||||
public InMemoryEphemeralRendezvousStore Store { get; }
|
||||
public TenantScope Scope { get; } = new(new GameId("space-game"), new EnvironmentId("test"));
|
||||
|
||||
public CreateListingCommand ListingCommand(
|
||||
string owner = "publisher-1",
|
||||
string? idempotencyKey = null,
|
||||
string? requestFingerprint = null)
|
||||
{
|
||||
int sequence = Interlocked.Increment(ref _sequence);
|
||||
return new(
|
||||
idempotencyKey ?? $"register-{sequence}",
|
||||
requestFingerprint ?? $"request-{sequence}",
|
||||
new ListingDefinition
|
||||
{
|
||||
ListingId = NewListingId(),
|
||||
LeaseId = NewLeaseId(),
|
||||
Scope = Scope,
|
||||
OwnerSubject = owner,
|
||||
RegionId = new RegionId("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.2.3",
|
||||
DisplayName = "Test host",
|
||||
Visibility = ListingVisibility.Public,
|
||||
TrustMode = PublisherTrustMode.ManagedDedicated,
|
||||
CurrentPlayers = 1,
|
||||
MaximumPlayers = 8,
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal) { ["mode"] = "coop" },
|
||||
LeaseFingerprint = Fingerprint($"lease-{sequence}"),
|
||||
HostPresenceHandle = NewHandle(),
|
||||
HostPresenceFingerprint = Fingerprint($"presence-{sequence}"),
|
||||
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
});
|
||||
}
|
||||
|
||||
public StoredListing CreateVisibleListing(out CreateListingCommand command)
|
||||
{
|
||||
command = ListingCommand();
|
||||
StoreResult<StoredListing> created = Store.CreateListing(command);
|
||||
Assert.True(created.Succeeded);
|
||||
StoreResult<StoredListing> bound = Store.BindHostPresence(new(
|
||||
command.Listing.HostPresenceHandle,
|
||||
command.Listing.HostPresenceFingerprint,
|
||||
PublicEndpoint(40_000),
|
||||
LocalEndpoint(40_000)));
|
||||
Assert.True(bound.Succeeded);
|
||||
return bound.Value!;
|
||||
}
|
||||
|
||||
public CreateJoinAttemptCommand AttemptCommand(StoredListing listing, string owner = "client-1")
|
||||
{
|
||||
int sequence = Interlocked.Increment(ref _sequence);
|
||||
return new()
|
||||
{
|
||||
IdempotencyOwner = owner,
|
||||
IdempotencyKey = $"join-{sequence}",
|
||||
RequestFingerprint = $"join-request-{sequence}",
|
||||
ClientSubject = owner,
|
||||
AttemptId = NewAttemptId(),
|
||||
MediationHandle = NewHandle(),
|
||||
Scope = listing.Definition.Scope,
|
||||
ListingId = listing.Definition.ListingId,
|
||||
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
|
||||
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
|
||||
ConnectionTicketFingerprint = Fingerprint($"ticket-{sequence}"),
|
||||
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
};
|
||||
}
|
||||
|
||||
public static SecretFingerprint Fingerprint(string value) => new(value);
|
||||
public static ObservedEndpoint PublicEndpoint(int port) => new(AddressFamilyKind.Ipv4, "203.0.113.10", port);
|
||||
public static ObservedEndpoint OtherPublicEndpoint(int port) => new(AddressFamilyKind.Ipv4, "198.51.100.20", port);
|
||||
public static ObservedEndpoint LocalEndpoint(int port) => new(AddressFamilyKind.Ipv4, "192.168.1.20", port);
|
||||
public static SessionListingId NewListingId() => new(Guid.NewGuid());
|
||||
public static LeaseId NewLeaseId() => new(Guid.NewGuid());
|
||||
public static JoinAttemptId NewAttemptId() => new(Guid.NewGuid());
|
||||
public static MediationHandle NewHandle() => new(Guid.NewGuid());
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
public sealed class InMemoryEphemeralRendezvousStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void IdempotencyRetentionMustCoverResourceLifetimes()
|
||||
{
|
||||
ManualRendezvousClock clock = new();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new InMemoryEphemeralRendezvousStore(
|
||||
new EphemeralStoreOptions { IdempotencyLifetime = TimeSpan.FromSeconds(5) },
|
||||
clock,
|
||||
clock));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateRegistrationIsIdempotentButChangedRequestConflicts()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
CreateListingCommand command = fixture.ListingCommand();
|
||||
|
||||
StoreResult<StoredListing> first = fixture.Store.CreateListing(command);
|
||||
StoreResult<StoredListing> duplicate = fixture.Store.CreateListing(command);
|
||||
StoreResult<StoredListing> changed = fixture.Store.CreateListing(command with { RequestFingerprint = "different" });
|
||||
|
||||
Assert.True(first.Succeeded);
|
||||
Assert.True(duplicate.Succeeded);
|
||||
Assert.True(duplicate.IsIdempotentReplay);
|
||||
Assert.Equal(first.Value!.Definition.ListingId, duplicate.Value!.Definition.ListingId);
|
||||
Assert.Equal(StoreResultCode.Conflict, changed.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaseAndPresenceExpiryUseMonotonicTime()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, true).Code);
|
||||
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, false).Succeeded);
|
||||
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(40));
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WallClockMovementDoesNotExpireOrExtendLease()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out CreateListingCommand command);
|
||||
|
||||
fixture.Clock.MoveWall(TimeSpan.FromDays(30));
|
||||
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, false).Succeeded);
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||
StoreResult<StoredListing> renewed = fixture.Store.RenewLease(new(
|
||||
listing.Definition.ListingId,
|
||||
listing.Definition.LeaseId,
|
||||
listing.Definition.LeaseFingerprint,
|
||||
listing.Definition.OwnerSubject,
|
||||
listing.Version));
|
||||
Assert.Equal(new DateTimeOffset(2026, 7, 16, 0, 1, 1, TimeSpan.Zero), renewed.Value!.LeaseExpiresAt);
|
||||
fixture.Clock.MoveWall(TimeSpan.FromDays(-60));
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(59));
|
||||
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, false).Succeeded);
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(command.Listing.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenewDeleteRaceIsAtomicAndDeleteAlwaysWinsEventually()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out CreateListingCommand command);
|
||||
using ManualResetEventSlim start = new(false);
|
||||
|
||||
Task<StoreResult<StoredListing>> renew = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return fixture.Store.RenewLease(new(
|
||||
listing.Definition.ListingId,
|
||||
listing.Definition.LeaseId,
|
||||
listing.Definition.LeaseFingerprint,
|
||||
listing.Definition.OwnerSubject,
|
||||
listing.Version));
|
||||
});
|
||||
Task<StoreResult<bool>> delete = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return fixture.Store.DeleteListing(new(
|
||||
command.Listing.ListingId,
|
||||
command.Listing.LeaseId,
|
||||
command.Listing.LeaseFingerprint,
|
||||
command.Listing.OwnerSubject));
|
||||
});
|
||||
|
||||
start.Set();
|
||||
await Task.WhenAll(renew, delete);
|
||||
StoreResult<StoredListing> renewResult = await renew;
|
||||
StoreResult<bool> deleteResult = await delete;
|
||||
|
||||
Assert.True(deleteResult.Succeeded);
|
||||
Assert.Contains(renewResult.Code, new[] { StoreResultCode.Success, StoreResultCode.NotFound });
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(command.Listing.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareAndSwapPreventsStaleRenewal()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||
RenewLeaseCommand command = new(
|
||||
listing.Definition.ListingId,
|
||||
listing.Definition.LeaseId,
|
||||
listing.Definition.LeaseFingerprint,
|
||||
listing.Definition.OwnerSubject,
|
||||
listing.Version);
|
||||
|
||||
StoreResult<StoredListing> first = fixture.Store.RenewLease(command);
|
||||
StoreResult<StoredListing> stale = fixture.Store.RenewLease(command);
|
||||
|
||||
Assert.Equal(2, first.Value!.Version);
|
||||
Assert.Equal(StoreResultCode.Conflict, stale.Code);
|
||||
Assert.Equal(2, stale.Value!.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoinRequiresExactScopeProtocolAndFreshHostPresence()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
CreateListingCommand listingCommand = fixture.ListingCommand();
|
||||
StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!;
|
||||
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing);
|
||||
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt).Code);
|
||||
fixture.Store.BindHostPresence(new(
|
||||
listingCommand.Listing.HostPresenceHandle,
|
||||
listingCommand.Listing.HostPresenceFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(40_000),
|
||||
null));
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with { ProtocolVersion = 8 }).Code);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with
|
||||
{
|
||||
Scope = new(new("other-game"), new("test")),
|
||||
}).Code);
|
||||
Assert.True(fixture.Store.CreateJoinAttempt(attempt).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateJoinIsIdempotentAndDoesNotAllocateTwice()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
|
||||
|
||||
StoreResult<StoredJoinAttempt> first = fixture.Store.CreateJoinAttempt(command);
|
||||
StoreResult<StoredJoinAttempt> duplicate = fixture.Store.CreateJoinAttempt(command);
|
||||
|
||||
Assert.True(first.Succeeded);
|
||||
Assert.True(duplicate.Succeeded);
|
||||
Assert.True(duplicate.IsIdempotentReplay);
|
||||
Assert.Equal(first.Value!.AttemptId, duplicate.Value!.AttemptId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BrowseReturnsOnlyFreshPublicCompatibleListingsInStableOrder()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing visible = fixture.CreateVisibleListing(out _);
|
||||
CreateListingCommand staleCommand = fixture.ListingCommand();
|
||||
fixture.Store.CreateListing(staleCommand);
|
||||
CreateListingCommand unlistedCommand = fixture.ListingCommand();
|
||||
unlistedCommand = unlistedCommand with
|
||||
{
|
||||
Listing = unlistedCommand.Listing with { Visibility = ListingVisibility.Unlisted },
|
||||
};
|
||||
fixture.Store.CreateListing(unlistedCommand);
|
||||
fixture.Store.BindHostPresence(new(
|
||||
unlistedCommand.Listing.HostPresenceHandle,
|
||||
unlistedCommand.Listing.HostPresenceFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(40_099),
|
||||
null));
|
||||
|
||||
StoreResult<IReadOnlyList<StoredListing>> result = fixture.Store.BrowseVisibleListings(new(
|
||||
fixture.Scope,
|
||||
visible.Definition.ProtocolVersion,
|
||||
visible.Definition.RegionId));
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Collection(result.Value!, item => Assert.Equal(visible.Definition.ListingId, item.Definition.ListingId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentEndpointBindingAcceptsOneCompleteEndpointOnly()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
|
||||
fixture.Store.CreateJoinAttempt(command);
|
||||
BindAttemptEndpointCommand first = new(
|
||||
command.MediationHandle,
|
||||
AttemptPeerRole.Client,
|
||||
command.ClientCapabilityFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(40_001),
|
||||
EphemeralStateFixture.LocalEndpoint(40_001));
|
||||
BindAttemptEndpointCommand second = first with
|
||||
{
|
||||
PublicEndpoint = EphemeralStateFixture.OtherPublicEndpoint(50_001),
|
||||
LocalEndpoint = null,
|
||||
};
|
||||
using ManualResetEventSlim start = new(false);
|
||||
|
||||
Task<StoreResult<StoredJoinAttempt>> left = Task.Run(() => { start.Wait(); return fixture.Store.BindAttemptEndpoint(first); });
|
||||
Task<StoreResult<StoredJoinAttempt>> right = Task.Run(() => { start.Wait(); return fixture.Store.BindAttemptEndpoint(second); });
|
||||
start.Set();
|
||||
await Task.WhenAll(left, right);
|
||||
StoreResult<StoredJoinAttempt> leftResult = await left;
|
||||
StoreResult<StoredJoinAttempt> rightResult = await right;
|
||||
|
||||
Assert.Equal(1, new[] { leftResult, rightResult }.Count(static result => result.Succeeded));
|
||||
Assert.Equal(1, new[] { leftResult, rightResult }.Count(static result => result.Code == StoreResultCode.ReplayRejected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttemptCapabilitiesAndIntroductionAreOneTime()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
|
||||
fixture.Store.CreateJoinAttempt(command);
|
||||
BindAttemptEndpointCommand host = new(
|
||||
command.MediationHandle,
|
||||
AttemptPeerRole.Host,
|
||||
command.HostCapabilityFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(40_010),
|
||||
null);
|
||||
BindAttemptEndpointCommand client = new(
|
||||
command.MediationHandle,
|
||||
AttemptPeerRole.Client,
|
||||
command.ClientCapabilityFingerprint,
|
||||
EphemeralStateFixture.OtherPublicEndpoint(40_020),
|
||||
null);
|
||||
|
||||
Assert.True(fixture.Store.BindAttemptEndpoint(host).Succeeded);
|
||||
Assert.True(fixture.Store.BindAttemptEndpoint(client).Succeeded);
|
||||
Assert.True(fixture.Store.BindAttemptEndpoint(client).IsIdempotentReplay);
|
||||
Assert.True(fixture.Store.ConsumeIntroduction(command.MediationHandle).Succeeded);
|
||||
Assert.Equal(StoreResultCode.ReplayRejected, fixture.Store.ConsumeIntroduction(command.MediationHandle).Code);
|
||||
Assert.Equal(StoreResultCode.ReplayRejected, fixture.Store.BindAttemptEndpoint(client with
|
||||
{
|
||||
PublicEndpoint = EphemeralStateFixture.OtherPublicEndpoint(40_021),
|
||||
}).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpiredAttemptCannotBeObservedBoundOrConsumed()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
|
||||
fixture.Store.CreateJoinAttempt(command);
|
||||
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
|
||||
command.MediationHandle,
|
||||
AttemptPeerRole.Client,
|
||||
command.ClientCapabilityFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(40_050),
|
||||
null)).Code);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.ConsumeIntroduction(command.MediationHandle).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericReplayConsumptionIsBoundedAndExpires()
|
||||
{
|
||||
EphemeralStoreOptions options = new() { MaxReplayEntries = 1 };
|
||||
EphemeralStateFixture fixture = new(options);
|
||||
|
||||
Assert.True(fixture.Store.ConsumeReplay(new("ticket", "one")).Succeeded);
|
||||
Assert.Equal(StoreResultCode.ReplayRejected, fixture.Store.ConsumeReplay(new("ticket", "one")).Code);
|
||||
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.ConsumeReplay(new("ticket", "two")).Code);
|
||||
fixture.Clock.Advance(options.ReplayLifetime);
|
||||
Assert.True(fixture.Store.ConsumeReplay(new("ticket", "two")).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresenceAttemptAndRevocationPoolsShedWithoutPartialMutation()
|
||||
{
|
||||
EphemeralStoreOptions options = new()
|
||||
{
|
||||
MaxPresenceBindings = 1,
|
||||
MaxJoinAttempts = 1,
|
||||
MaxRevocations = 1,
|
||||
};
|
||||
EphemeralStateFixture fixture = new(options);
|
||||
StoredListing first = fixture.CreateVisibleListing(out CreateListingCommand firstCommand);
|
||||
CreateListingCommand secondCommand = fixture.ListingCommand(owner: "publisher-2");
|
||||
fixture.Store.CreateListing(secondCommand);
|
||||
|
||||
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.BindHostPresence(new(
|
||||
secondCommand.Listing.HostPresenceHandle,
|
||||
secondCommand.Listing.HostPresenceFingerprint,
|
||||
EphemeralStateFixture.OtherPublicEndpoint(42_000),
|
||||
null)).Code);
|
||||
Assert.True(fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(first)).Succeeded);
|
||||
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(first, "client-2")).Code);
|
||||
Assert.True(fixture.Store.RevokePrincipal("unrelated", TimeSpan.FromMinutes(1)).Succeeded);
|
||||
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.RevokePrincipal(firstCommand.Listing.OwnerSubject, TimeSpan.FromMinutes(1)).Code);
|
||||
Assert.True(fixture.Store.GetListing(first.Definition.ListingId, true).Succeeded);
|
||||
Assert.True(fixture.Store.GetListing(secondCommand.Listing.ListingId, false).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExhaustionShedsNewListingWithoutMutatingExistingState()
|
||||
{
|
||||
EphemeralStoreOptions options = new() { MaxListings = 1 };
|
||||
EphemeralStateFixture fixture = new(options);
|
||||
CreateListingCommand first = fixture.ListingCommand();
|
||||
CreateListingCommand second = fixture.ListingCommand();
|
||||
|
||||
Assert.True(fixture.Store.CreateListing(first).Succeeded);
|
||||
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
|
||||
Assert.True(fixture.Store.GetListing(first.Listing.ListingId, false).Succeeded);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(second.Listing.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdempotencyPoolExhaustionDoesNotCreateUntrackedResource()
|
||||
{
|
||||
EphemeralStoreOptions options = new() { MaxListings = 2, MaxIdempotencyEntries = 1 };
|
||||
EphemeralStateFixture fixture = new(options);
|
||||
CreateListingCommand first = fixture.ListingCommand();
|
||||
CreateListingCommand second = fixture.ListingCommand();
|
||||
|
||||
Assert.True(fixture.Store.CreateListing(first).Succeeded);
|
||||
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
|
||||
Assert.True(fixture.Store.GetListing(first.Listing.ListingId, false).Succeeded);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(second.Listing.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyQuotasAreCheckedInsideAtomicCreation()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
CreateListingCommand first = fixture.ListingCommand(owner: "publisher-quota") with { OwnerListingLimit = 1 };
|
||||
CreateListingCommand second = fixture.ListingCommand(owner: "publisher-quota") with { OwnerListingLimit = 1 };
|
||||
Assert.True(fixture.Store.CreateListing(first).Succeeded);
|
||||
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
|
||||
|
||||
fixture.Store.BindHostPresence(new(
|
||||
first.Listing.HostPresenceHandle,
|
||||
first.Listing.HostPresenceFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(42_100),
|
||||
null));
|
||||
StoredListing listing = fixture.Store.GetListing(first.Listing.ListingId, true).Value!;
|
||||
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing) with { ScopeAttemptLimit = 1 };
|
||||
Assert.True(fixture.Store.CreateJoinAttempt(attempt).Succeeded);
|
||||
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateJoinAttempt(
|
||||
fixture.AttemptCommand(listing, "client-quota-2") with { ScopeAttemptLimit = 1 }).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidDefaultSecurityValuesCannotEnterStore()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
CreateListingCommand command = fixture.ListingCommand();
|
||||
|
||||
Assert.Throws<ArgumentException>(() => fixture.Store.CreateListing(command with
|
||||
{
|
||||
Listing = command.Listing with { LeaseFingerprint = default },
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevocationRemovesEveryPathAndBlocksNewWorkAtomically()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out CreateListingCommand command);
|
||||
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing, command.Listing.OwnerSubject);
|
||||
fixture.Store.CreateJoinAttempt(attempt);
|
||||
|
||||
StoreResult<int> revoked = fixture.Store.RevokePrincipal(command.Listing.OwnerSubject, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.True(revoked.Succeeded);
|
||||
Assert.Equal(2, revoked.Value);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
|
||||
attempt.MediationHandle,
|
||||
AttemptPeerRole.Client,
|
||||
attempt.ClientCapabilityFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(40_030),
|
||||
null)).Code);
|
||||
Assert.Equal(StoreResultCode.Revoked, fixture.Store.CreateListing(fixture.ListingCommand(owner: command.Listing.OwnerSubject)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartHasNewGenerationAndNoEphemeralState()
|
||||
{
|
||||
EphemeralStateFixture before = new();
|
||||
StoredListing listing = before.CreateVisibleListing(out _);
|
||||
EphemeralStateFixture after = new();
|
||||
|
||||
Assert.NotEqual(before.Store.InstanceId, after.Store.InstanceId);
|
||||
Assert.Equal(StoreResultCode.NotFound, after.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrainRejectsNewWorkAllowsInflightCompletionThenClearsState()
|
||||
{
|
||||
EphemeralStoreOptions options = new() { GracefulDrainLifetime = TimeSpan.FromSeconds(5) };
|
||||
EphemeralStateFixture fixture = new(options);
|
||||
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing);
|
||||
fixture.Store.CreateJoinAttempt(attempt);
|
||||
fixture.Store.BeginDrain();
|
||||
|
||||
Assert.Equal(StoreResultCode.Draining, fixture.Store.CreateListing(fixture.ListingCommand()).Code);
|
||||
Assert.Equal(StoreResultCode.Draining, fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(listing)).Code);
|
||||
Assert.True(fixture.Store.BindAttemptEndpoint(new(
|
||||
attempt.MediationHandle,
|
||||
AttemptPeerRole.Client,
|
||||
attempt.ClientCapabilityFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(41_000),
|
||||
null)).Succeeded);
|
||||
|
||||
fixture.Clock.Advance(options.GracefulDrainLifetime);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
|
||||
attempt.MediationHandle,
|
||||
AttemptPeerRole.Host,
|
||||
attempt.HostCapabilityFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(41_001),
|
||||
null)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrecancelledOperationHasNoPartialEffect()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
CreateListingCommand command = fixture.ListingCommand();
|
||||
using CancellationTokenSource cancellation = new();
|
||||
cancellation.Cancel();
|
||||
|
||||
Assert.Throws<OperationCanceledException>(() => fixture.Store.CreateListing(command, cancellation.Token));
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(command.Listing.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnavailableStoreFailsNewAuthorizationClosedAndErasesActiveState()
|
||||
{
|
||||
EphemeralStateFixture fixture = new();
|
||||
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||
fixture.Store.MarkUnavailable();
|
||||
|
||||
Assert.Equal(StoreResultCode.ServiceUnavailable, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||
Assert.Equal(StoreResultCode.ServiceUnavailable, fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(listing)).Code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
public sealed class StoreResultMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryStoreResultHasOneSharedContractErrorMapping()
|
||||
{
|
||||
Dictionary<StoreResultCode, RendezvousErrorCode> expected = new()
|
||||
{
|
||||
[StoreResultCode.Success] = RendezvousErrorCode.None,
|
||||
[StoreResultCode.NotFound] = RendezvousErrorCode.NotFound,
|
||||
[StoreResultCode.Expired] = RendezvousErrorCode.Expired,
|
||||
[StoreResultCode.Revoked] = RendezvousErrorCode.Forbidden,
|
||||
[StoreResultCode.Conflict] = RendezvousErrorCode.Conflict,
|
||||
[StoreResultCode.CapacityExceeded] = RendezvousErrorCode.CapacityExceeded,
|
||||
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
|
||||
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
|
||||
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
|
||||
};
|
||||
|
||||
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count);
|
||||
foreach (KeyValuePair<StoreResultCode, RendezvousErrorCode> item in expected)
|
||||
{
|
||||
Assert.Equal(item.Value, item.Key.ToContractError());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,74 @@
|
||||
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult
|
||||
ENUM Accepted=1
|
||||
ENUM NotFound=2
|
||||
ENUM Expired=3
|
||||
ENUM Rejected=4
|
||||
ENUM AlreadyConsumed=5
|
||||
ENUM Revoked=6
|
||||
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
|
||||
CTOR (System.Int32 maximumAuthorizedTickets)
|
||||
METHOD FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult Consume(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
|
||||
METHOD System.Void Dispose()
|
||||
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
|
||||
METHOD System.String ToString()
|
||||
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
|
||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
|
||||
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousSessionBrowserClient
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
|
||||
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;}
|
||||
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason
|
||||
ENUM Cancelled=1
|
||||
ENUM Disposed=2
|
||||
ENUM LeaseLost=3
|
||||
ENUM Failed=4
|
||||
TYPE FinalFactory.Rendezvous.Client.PublishedSession
|
||||
PROP System.DateTimeOffset ExpiresAt {get;}
|
||||
PROP System.String HostPresenceCapability {get;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;}
|
||||
PROP System.Int32 HostPresenceRefreshAfterSeconds {get;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;}
|
||||
PROP System.Int32 LeaseRenewAfterSeconds {get;}
|
||||
PROP System.String LeaseToken {get;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;}
|
||||
METHOD System.String ToString()
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
|
||||
CTOR ()
|
||||
PROP System.TimeSpan InitialRetryDelay {get;set;}
|
||||
PROP System.Double JitterRatio {get;set;}
|
||||
PROP System.TimeSpan MaximumRetryDelay {get;set;}
|
||||
PROP System.Int32 MaximumSafeRetries {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult
|
||||
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable<System.Int32> retryAfterSeconds)
|
||||
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Success(T value)
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
|
||||
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||
PROP System.Boolean IsSuccess {get;}
|
||||
PROP System.String Message {get;}
|
||||
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;}
|
||||
PROP T Value {get;}
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient
|
||||
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||
METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousSessionBrowserClient
|
||||
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
|
||||
EVENT System.EventHandler LeaseLost
|
||||
METHOD System.Threading.Tasks.ValueTask DisposeAsync()
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.LeaseMaintenanceResult> RunAsync(System.Threading.CancellationToken cancellationToken)
|
||||
@@ -0,0 +1,319 @@
|
||||
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 System.Boolean ExcludeFull {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 System.Int32 HostPresenceRefreshAfterSeconds {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;}
|
||||
PROP System.Int32 LeaseRenewAfterSeconds {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;}
|
||||
PROP System.Int32 RenewAfterSeconds {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;}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user