Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49564c7e7e | |||
| 02ca502a76 |
+247
-14
@@ -75,8 +75,68 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "Not Implemented",
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"410": {
|
||||
"description": "Gone",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -85,7 +145,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"PublisherBearer": [ ]
|
||||
}
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -211,8 +276,68 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "Not Implemented",
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"410": {
|
||||
"description": "Gone",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -221,7 +346,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"PublisherBearer": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/sessions/{listingId}": {
|
||||
@@ -254,8 +384,48 @@
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"501": {
|
||||
"description": "Not Implemented",
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -264,7 +434,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"PublisherBearer": [ ]
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
@@ -295,8 +470,38 @@
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"501": {
|
||||
"description": "Not Implemented",
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -305,7 +510,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"PublisherBearer": [ ]
|
||||
}
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -875,7 +1085,9 @@
|
||||
"leaseToken",
|
||||
"hostPresenceHandle",
|
||||
"hostPresenceCapability",
|
||||
"expiresAt"
|
||||
"expiresAt",
|
||||
"leaseRenewAfterSeconds",
|
||||
"hostPresenceRefreshAfterSeconds"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -901,6 +1113,14 @@
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"leaseRenewAfterSeconds": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"hostPresenceRefreshAfterSeconds": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -942,7 +1162,8 @@
|
||||
"RenewLeaseResponse": {
|
||||
"required": [
|
||||
"contractVersion",
|
||||
"expiresAt"
|
||||
"expiresAt",
|
||||
"renewAfterSeconds"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -953,6 +1174,10 @@
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"renewAfterSeconds": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1115,6 +1340,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"PublisherBearer": {
|
||||
"type": "http",
|
||||
"description": "Tenant-scoped publisher credential issued during game provisioning.",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "rv1 publisher credential"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -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.
|
||||
@@ -6,6 +6,8 @@ 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)
|
||||
- [Threat model](../security/threat-model.md)
|
||||
- [Security promise and test matrix](../security/control-matrix.md)
|
||||
- [Versioned HTTP and UDP contracts](../contracts/README.md)
|
||||
|
||||
@@ -46,10 +46,12 @@ public static class ContractValidation
|
||||
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
|
||||
|
||||
public static bool IsBuildVersionValid(string? value) =>
|
||||
IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
|
||||
!string.IsNullOrWhiteSpace(value)
|
||||
&& IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
|
||||
|
||||
public static bool IsDisplayNameValid(string? value) =>
|
||||
IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
|
||||
!string.IsNullOrWhiteSpace(value)
|
||||
&& IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
|
||||
|
||||
public static bool IsOpaqueHttpCredentialValid(string? value) =>
|
||||
value is not null
|
||||
|
||||
@@ -99,6 +99,12 @@ public sealed class RegisterSessionResponse
|
||||
|
||||
[JsonRequired]
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public int LeaseRenewAfterSeconds { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public int HostPresenceRefreshAfterSeconds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RenewLeaseRequest
|
||||
@@ -117,6 +123,9 @@ public sealed class RenewLeaseResponse
|
||||
|
||||
[JsonRequired]
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public int RenewAfterSeconds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateSessionRequest
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Http;
|
||||
@@ -14,22 +17,41 @@ internal static class ContractEndpoints
|
||||
sessions.MapPost("/", RegisterSession)
|
||||
.Accepts<RegisterSessionRequest>("application/json")
|
||||
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.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>(NotImplementedStatus)
|
||||
.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>(NotImplementedStatus)
|
||||
.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>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("DeleteSession");
|
||||
sessions.MapGet("/", BrowseSessions)
|
||||
.Produces<BrowseSessionsResponse>()
|
||||
@@ -61,20 +83,115 @@ internal static class ContractEndpoints
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult RegisterSession([FromBody] RegisterSessionRequest request) =>
|
||||
NotImplemented();
|
||||
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) => NotImplemented();
|
||||
[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) => NotImplemented();
|
||||
[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) => NotImplemented();
|
||||
[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,
|
||||
@@ -109,4 +226,72 @@ internal static class ContractEndpoints
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
@@ -12,6 +14,7 @@ bool isOpenApiGeneration = string.Equals(
|
||||
StringComparison.Ordinal);
|
||||
|
||||
builder.Services.AddOpenApi("v1", static options =>
|
||||
{
|
||||
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
|
||||
{
|
||||
Type type = context.JsonTypeInfo.Type;
|
||||
@@ -31,9 +34,65 @@ builder.Services.AddOpenApi("v1", static options =>
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -55,6 +114,11 @@ else
|
||||
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(new ProvisioningReadiness(true));
|
||||
}
|
||||
|
||||
@@ -74,7 +138,9 @@ if (!isOpenApiGeneration)
|
||||
}
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
||||
|
||||
app.UseExceptionHandler();
|
||||
app.MapOpenApi();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
app.MapGet(
|
||||
@@ -85,8 +151,14 @@ app.MapGet(
|
||||
.WithTags("Health");
|
||||
app.MapGet(
|
||||
"/health/ready",
|
||||
static (UdpMediatorService mediator, ProvisioningReadiness provisioning) =>
|
||||
mediator.LocalEndpoint is null || !provisioning.IsReady
|
||||
static (
|
||||
UdpMediatorService mediator,
|
||||
ProvisioningReadiness provisioning,
|
||||
IEphemeralRendezvousStore state) =>
|
||||
mediator.LocalEndpoint is null
|
||||
|| !provisioning.IsReady
|
||||
|| !state.IsAvailable
|
||||
|| state.IsDraining
|
||||
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
|
||||
: Results.Ok(new HealthResponse { Status = "ready" }))
|
||||
.Produces<HealthResponse>()
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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);
|
||||
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 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,452 @@
|
||||
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(MapStore(created.Code));
|
||||
}
|
||||
|
||||
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(MapStore(renewed.Code));
|
||||
}
|
||||
|
||||
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(MapStore(updated.Code));
|
||||
}
|
||||
|
||||
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(MapStore(deleted.Code));
|
||||
}
|
||||
|
||||
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 MapStore(found.Code);
|
||||
}
|
||||
|
||||
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 RendezvousErrorCode MapStore(StoreResultCode code) => code switch
|
||||
{
|
||||
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,
|
||||
};
|
||||
|
||||
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,323 @@
|
||||
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 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(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
||||
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
||||
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
||||
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);
|
||||
|
||||
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 int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
||||
}
|
||||
|
||||
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 DateTimeOffset ExpiresAt { get; init; }
|
||||
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
||||
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||
public required bool IntroductionConsumed { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BindAttemptEndpointCommand(
|
||||
MediationHandle Handle,
|
||||
AttemptPeerRole Role,
|
||||
SecretFingerprint CapabilityFingerprint,
|
||||
ObservedEndpoint PublicEndpoint,
|
||||
ObservedEndpoint? LocalEndpoint);
|
||||
|
||||
internal sealed record IntroductionEndpoints(
|
||||
JoinAttemptId AttemptId,
|
||||
AttemptEndpointBinding Host,
|
||||
AttemptEndpointBinding Client);
|
||||
|
||||
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<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, 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,863 @@
|
||||
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)
|
||||
{
|
||||
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)
|
||||
&& _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<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>(_ =>
|
||||
{
|
||||
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;
|
||||
return new(StoreResultCode.Success, new(
|
||||
attempt.Command.AttemptId,
|
||||
attempt.HostEndpoint,
|
||||
attempt.ClientEndpoint));
|
||||
}, 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,
|
||||
ExpiresAt = entry.WallExpiresAt,
|
||||
HostEndpoint = entry.HostEndpoint,
|
||||
ClientEndpoint = entry.ClientEndpoint,
|
||||
IntroductionConsumed = entry.IntroductionConsumed,
|
||||
};
|
||||
|
||||
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.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 TimeSpan Deadline { get; } = deadline;
|
||||
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
|
||||
public AttemptEndpointBinding? HostEndpoint { get; set; }
|
||||
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
||||
public bool IntroductionConsumed { get; set; }
|
||||
}
|
||||
|
||||
private sealed record IdempotencyEntry(
|
||||
string RequestFingerprint,
|
||||
object ResourceId,
|
||||
TimeSpan Deadline);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@ 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()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ 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) },
|
||||
|
||||
@@ -64,5 +64,26 @@ public sealed class OpenApiCompatibilityTests
|
||||
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 _));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,145 @@
|
||||
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.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>();
|
||||
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);
|
||||
|
||||
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,294 @@
|
||||
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),
|
||||
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,109 @@
|
||||
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}"),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,9 @@ TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionResponse
|
||||
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
|
||||
@@ -250,6 +252,7 @@ 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;}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","leaseId":"11112233-4455-6677-8899-aabbccddeeff","leaseToken":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","hostPresenceHandle":"22222233-4455-6677-8899-aabbccddeeff","hostPresenceCapability":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","expiresAt":"2026-07-16T12:01:00+00:00","leaseRenewAfterSeconds":30,"hostPresenceRefreshAfterSeconds":10}
|
||||
Reference in New Issue
Block a user