Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e3be2cad1 | |||
| 94aba8a3bb | |||
| b4b6072fe1 | |||
| 6d076c281a | |||
| 1baa1055dc | |||
| 06c3973ce7 | |||
| a9a2b3db35 | |||
| 49564c7e7e | |||
| 02ca502a76 |
@@ -17,7 +17,7 @@ Rendezvous is intended to provide:
|
|||||||
- Isolation by game, environment, protocol version, and region.
|
- Isolation by game, environment, protocol version, and region.
|
||||||
- Operational health, metrics, logging, administration, and rate limiting.
|
- Operational health, metrics, logging, administration, and rate limiting.
|
||||||
|
|
||||||
UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server or a future relay service.
|
UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server. The v1 SDK returns an optional game-configured endpoint for an explicit caller decision; it never routes automatically, and v1 does not provide a relay.
|
||||||
|
|
||||||
## Connection flow
|
## Connection flow
|
||||||
|
|
||||||
@@ -75,7 +75,13 @@ The initial service does not provide:
|
|||||||
|
|
||||||
## Project status
|
## Project status
|
||||||
|
|
||||||
Rendezvous is currently in its initial design and bootstrap stage. The first implementation should establish the contracts, directory leases, LiteNetLib mediator, client SDK, thin test client, and a three-party integration test before either game depends on it for production connectivity.
|
Rendezvous is under active roadmap development. The versioned contracts,
|
||||||
|
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
|
||||||
|
SDK coordination, typed connection outcomes, and thin public-SDK diagnostic client
|
||||||
|
are implemented. Deployment hardening, the broader NAT-topology harness, and
|
||||||
|
the production-readiness roadmap remain in progress;
|
||||||
|
participating games must not treat the current repository as a finished production
|
||||||
|
service until those gates land.
|
||||||
|
|
||||||
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
|
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
|
||||||
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
|
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
|
||||||
@@ -83,6 +89,8 @@ The frozen v1 wire surface is documented in the
|
|||||||
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
|
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
|
||||||
Tenant policy, publisher/operator principals, and production key custody are
|
Tenant policy, publisher/operator principals, and production key custody are
|
||||||
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
|
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
|
||||||
|
The scriptable host/browser/join diagnostic and its stable automation contract are
|
||||||
|
documented in the [TestClient integration guide](docs/integration/test-client.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+582
-24
@@ -75,8 +75,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -85,7 +145,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"PublisherBearer": [ ]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -142,6 +207,13 @@
|
|||||||
"format": "int32"
|
"format": "int32"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "excludeFull",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "cursor",
|
"name": "cursor",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
@@ -161,8 +233,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -211,8 +293,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -221,7 +363,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"PublisherBearer": [ ]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/sessions/{listingId}": {
|
"/v1/sessions/{listingId}": {
|
||||||
@@ -254,8 +401,48 @@
|
|||||||
"204": {
|
"204": {
|
||||||
"description": "No Content"
|
"description": "No Content"
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -264,7 +451,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"PublisherBearer": [ ]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -295,8 +487,38 @@
|
|||||||
"204": {
|
"204": {
|
||||||
"description": "No Content"
|
"description": "No Content"
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -305,7 +527,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"PublisherBearer": [ ]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -320,6 +547,40 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "contractVersion",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gameId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "environmentId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "protocolVersion",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint32"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -333,8 +594,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"description": "Bad Request",
|
||||||
|
"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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -405,8 +686,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"description": "Bad Request",
|
||||||
|
"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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -445,8 +746,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"description": "Bad Request",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -458,6 +809,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v1/join-attempts/{attemptId}": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Join attempts"
|
||||||
|
],
|
||||||
|
"operationId": "CancelJoinAttempt",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "attemptId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "X-Rendezvous-Client-Punch-Capability",
|
||||||
|
"in": "header",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"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": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JoinAttemptCapability": [ ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v1/join-attempts/{attemptId}/outcome": {
|
"/v1/join-attempts/{attemptId}/outcome": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -472,6 +889,14 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "X-Rendezvous-Client-Punch-Capability",
|
||||||
|
"in": "header",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -495,8 +920,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"description": "Bad Request",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -505,7 +960,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JoinAttemptCapability": [ ]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -600,6 +1060,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ConnectionElapsedBucket": {
|
||||||
|
"enum": [
|
||||||
|
"underOneSecond",
|
||||||
|
"oneToFiveSeconds",
|
||||||
|
"fiveToFifteenSeconds",
|
||||||
|
"fifteenToThirtySeconds",
|
||||||
|
"thirtySecondsOrMore"
|
||||||
|
]
|
||||||
|
},
|
||||||
"ConnectionOutcomeKind": {
|
"ConnectionOutcomeKind": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"connected",
|
"connected",
|
||||||
@@ -610,7 +1079,19 @@
|
|||||||
"serviceRejected",
|
"serviceRejected",
|
||||||
"hostRejected",
|
"hostRejected",
|
||||||
"transportFailed",
|
"transportFailed",
|
||||||
"fallbackOffered"
|
"fallbackOffered",
|
||||||
|
"directoryNotFound",
|
||||||
|
"attemptExpired",
|
||||||
|
"unauthorized",
|
||||||
|
"rateLimited",
|
||||||
|
"noHostPresence",
|
||||||
|
"serviceUnavailable",
|
||||||
|
"mediatorUnavailable",
|
||||||
|
"punchTimedOut",
|
||||||
|
"directConnectTimedOut",
|
||||||
|
"transportError",
|
||||||
|
"managerStopped",
|
||||||
|
"disposed"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"CreateJoinAttemptRequest": {
|
"CreateJoinAttemptRequest": {
|
||||||
@@ -652,6 +1133,7 @@
|
|||||||
"attemptId",
|
"attemptId",
|
||||||
"mediationHandle",
|
"mediationHandle",
|
||||||
"clientPunchCapability",
|
"clientPunchCapability",
|
||||||
|
"connectionTicketDigest",
|
||||||
"expiresAt"
|
"expiresAt"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -669,6 +1151,9 @@
|
|||||||
"clientPunchCapability": {
|
"clientPunchCapability": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"connectionTicketDigest": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@@ -744,6 +1229,8 @@
|
|||||||
"attemptId",
|
"attemptId",
|
||||||
"mediationHandle",
|
"mediationHandle",
|
||||||
"hostPunchCapability",
|
"hostPunchCapability",
|
||||||
|
"connectionTicketDigest",
|
||||||
|
"isCancelled",
|
||||||
"expiresAt"
|
"expiresAt"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -757,6 +1244,12 @@
|
|||||||
"hostPunchCapability": {
|
"hostPunchCapability": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"connectionTicketDigest": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"isCancelled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@@ -864,6 +1357,16 @@
|
|||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dedicatedFallback": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/NetworkEndpoint"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -875,7 +1378,9 @@
|
|||||||
"leaseToken",
|
"leaseToken",
|
||||||
"hostPresenceHandle",
|
"hostPresenceHandle",
|
||||||
"hostPresenceCapability",
|
"hostPresenceCapability",
|
||||||
"expiresAt"
|
"expiresAt",
|
||||||
|
"leaseRenewAfterSeconds",
|
||||||
|
"hostPresenceRefreshAfterSeconds"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -901,6 +1406,14 @@
|
|||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"leaseRenewAfterSeconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"hostPresenceRefreshAfterSeconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -942,7 +1455,8 @@
|
|||||||
"RenewLeaseResponse": {
|
"RenewLeaseResponse": {
|
||||||
"required": [
|
"required": [
|
||||||
"contractVersion",
|
"contractVersion",
|
||||||
"expiresAt"
|
"expiresAt",
|
||||||
|
"renewAfterSeconds"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -953,14 +1467,17 @@
|
|||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"renewAfterSeconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ReportConnectionOutcomeRequest": {
|
"ReportConnectionOutcomeRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"contractVersion",
|
"contractVersion",
|
||||||
"outcome",
|
"outcome"
|
||||||
"elapsedMilliseconds"
|
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -971,6 +1488,9 @@
|
|||||||
"outcome": {
|
"outcome": {
|
||||||
"$ref": "#/components/schemas/ConnectionOutcomeKind"
|
"$ref": "#/components/schemas/ConnectionOutcomeKind"
|
||||||
},
|
},
|
||||||
|
"elapsedBucket": {
|
||||||
|
"$ref": "#/components/schemas/ConnectionElapsedBucket"
|
||||||
|
},
|
||||||
"elapsedMilliseconds": {
|
"elapsedMilliseconds": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
@@ -986,7 +1506,8 @@
|
|||||||
"ReportConnectionOutcomeResponse": {
|
"ReportConnectionOutcomeResponse": {
|
||||||
"required": [
|
"required": [
|
||||||
"contractVersion",
|
"contractVersion",
|
||||||
"accepted"
|
"accepted",
|
||||||
|
"isDuplicate"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -996,6 +1517,9 @@
|
|||||||
},
|
},
|
||||||
"accepted": {
|
"accepted": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"isDuplicate": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1073,6 +1597,16 @@
|
|||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dedicatedFallback": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/NetworkEndpoint"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1112,9 +1646,33 @@
|
|||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dedicatedFallback": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/NetworkEndpoint"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"securitySchemes": {
|
||||||
|
"PublisherBearer": {
|
||||||
|
"type": "http",
|
||||||
|
"description": "Tenant-scoped publisher credential issued during game provisioning.",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "rv1 publisher credential"
|
||||||
|
},
|
||||||
|
"JoinAttemptCapability": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"description": "Attempt-scoped client capability returned only to the joining caller.",
|
||||||
|
"name": "X-Rendezvous-Client-Punch-Capability",
|
||||||
|
"in": "header"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ them but must not raise them without security review.
|
|||||||
| Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort |
|
| Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort |
|
||||||
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
|
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
|
||||||
| Opaque HTTP credential | 1,024 bytes encoded |
|
| Opaque HTTP credential | 1,024 bytes encoded |
|
||||||
| UDP capability or ticket | 768 bytes encoded, with the complete datagram still at most 1,200 bytes |
|
| UDP capability or connection ticket | 192 base64url characters; NAT punch capabilities also remain below LiteNetLib's 256-character token ceiling; complete datagram at most 1,200 bytes |
|
||||||
| Clock skew | 30 seconds maximum when validating issued/not-before/expiry times |
|
| Clock skew | 30 seconds maximum when validating issued/not-before/expiry times |
|
||||||
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
|
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
|
||||||
| Host presence freshness | 20 seconds |
|
| Host presence freshness | 20 seconds |
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# ADR 0004: atomic ephemeral state and single-active availability
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #6
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Listings, leases, endpoint observations, join attempts, and replay decisions must
|
||||||
|
move together. A partially committed authorization can expose an expired listing,
|
||||||
|
reuse a capability, or introduce an endpoint that was never authorized. V1 is a
|
||||||
|
single-active service, so it needs honest bounded in-memory behavior rather than
|
||||||
|
a database-shaped abstraction that implies unavailable durability or scale.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`IEphemeralRendezvousStore` is the atomic boundary for directory, lease, presence,
|
||||||
|
attempt, endpoint, replay, revocation, and drain transitions. The v1 implementation
|
||||||
|
serializes each transition under one process-local lock. This deliberately favors
|
||||||
|
simple, auditable correctness at the initial 25,000-listing/10,000-attempt ceiling.
|
||||||
|
It retains only immutable listing data, opaque credential fingerprints, observed
|
||||||
|
endpoints, monotonic deadlines, and bounded idempotency/replay records.
|
||||||
|
|
||||||
|
Every collection has an independent configured ceiling. An operation checks all
|
||||||
|
of the capacity it needs before changing any collection. Exhaustion returns
|
||||||
|
`CapacityExceeded`; it does not evict live state, partially insert an operation,
|
||||||
|
or grow a fallback queue. Policy-provided per-owner listing and per-tenant active
|
||||||
|
attempt quotas are evaluated inside the same creation transition, so concurrent
|
||||||
|
requests cannot pass a check performed outside the store. New join authorization returns `ServiceUnavailable`
|
||||||
|
when the atomic store is unavailable and `Draining` once drain starts.
|
||||||
|
|
||||||
|
### Time and cleanup
|
||||||
|
|
||||||
|
Expiry uses an injected monotonic clock. Wall time is used only to return an
|
||||||
|
informational `ExpiresAt` value. Moving the wall clock forward or backward cannot
|
||||||
|
expire or prolong authority. Cleanup runs deterministically at the start of every
|
||||||
|
store operation and removes presence, attempts, listings, replay entries,
|
||||||
|
idempotency records, and revocations at their deadline. Removal of a listing also
|
||||||
|
removes its presence handle and every linked attempt before another caller can
|
||||||
|
observe the store.
|
||||||
|
|
||||||
|
### Concurrency and idempotency
|
||||||
|
|
||||||
|
- Listing registration and join-attempt creation use a tenant-and-owner-scoped idempotency
|
||||||
|
key plus a canonical request fingerprint. An exact duplicate returns the
|
||||||
|
original live result; reuse with different input returns `Conflict`; replay
|
||||||
|
after the resource has expired returns `Expired` until the bounded idempotency
|
||||||
|
record itself expires. Configuration requires idempotency retention to cover
|
||||||
|
every listing and attempt lifetime, preventing a live duplicate after eviction.
|
||||||
|
- Lease renewal is compare-and-swap by version. A stale renewal returns the latest
|
||||||
|
version as `Conflict`. Renew/delete races are serialized: renewal either commits
|
||||||
|
before deletion or observes the listing as absent.
|
||||||
|
- Host presence refresh is an atomic whole-endpoint replacement because NAT
|
||||||
|
mappings can legitimately change. Attempt capabilities are different: the
|
||||||
|
first endpoint bound for each role wins, an identical datagram is idempotent,
|
||||||
|
and a different replay is rejected. Introduction is consumed once atomically.
|
||||||
|
- Cancellation is checked before waiting for the lock and again after acquiring
|
||||||
|
it. A cancellation observed at either point makes no change. Once a synchronous
|
||||||
|
transition starts, it completes atomically and does not expose partial state.
|
||||||
|
|
||||||
|
### Visibility and revocation
|
||||||
|
|
||||||
|
A listing is visible or joinable only when its lease and authenticated UDP host
|
||||||
|
presence are both fresh. Public browsing is tenant/protocol scoped, excludes
|
||||||
|
unlisted sessions, and uses a stable listing-ID order with the contract page
|
||||||
|
ceiling. Revoking a listing or principal removes every listing, presence, and
|
||||||
|
attempt path in the same transition. A revocation is inserted before removal;
|
||||||
|
if the bounded revocation pool is full, the operation rejects without deleting
|
||||||
|
anything.
|
||||||
|
|
||||||
|
### Restart and graceful drain
|
||||||
|
|
||||||
|
A process restart creates a new store instance ID and starts empty. Old listing,
|
||||||
|
lease, attempt, endpoint, idempotency, and consumption state is not recovered.
|
||||||
|
Publishers must re-register; old callers receive typed `NotFound`, `Expired`, or
|
||||||
|
`ServiceUnavailable` outcomes rather than an ambiguous success. No database is
|
||||||
|
required or supported for the single-active MVP.
|
||||||
|
|
||||||
|
Drain is idempotent. It immediately rejects new registrations, attempts, and
|
||||||
|
lease extensions, while already-created attempts may bind endpoints and consume
|
||||||
|
their introduction during the configured window (at most 30 seconds). At the
|
||||||
|
deadline all active state is cleared atomically. Readiness is false while draining
|
||||||
|
or unavailable, and application shutdown starts drain before teardown.
|
||||||
|
|
||||||
|
## Future shared-store mapping
|
||||||
|
|
||||||
|
The interface uses explicit typed outcomes, TTLs, compare-and-swap versions,
|
||||||
|
idempotency records, and all-or-nothing multi-record transitions. A future Redis
|
||||||
|
implementation therefore requires authenticated transport, tenant-prefixed keys,
|
||||||
|
server-side scripts or transactions for each transition, TTLs based on the store's
|
||||||
|
authoritative time, and deterministic mediator routing. It must preserve these
|
||||||
|
semantics and pass the same contract tests before issue #18 may enable more than
|
||||||
|
one active instance.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- V1 has deterministic failure and restart behavior without durable gameplay state.
|
||||||
|
- A single lock is a measured capacity constraint, not a claim of horizontal scale.
|
||||||
|
- Transport and HTTP modules cannot bypass the store for authorization decisions.
|
||||||
|
- Operational code must treat `CapacityExceeded`, `Draining`, and
|
||||||
|
`ServiceUnavailable` as normal typed overload/availability outcomes.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 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 frozen `HostPresence` envelope or native LiteNetLib
|
||||||
|
host-presence request 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
|
||||||
|
packet. Invalid or unknown inputs receive no response. ADR 0009 defines the later
|
||||||
|
attempt-role use of frozen `ClientPresence` and native host/client requests.
|
||||||
|
Presence expiry demotes public visibility but keeps the lease, so the same handle
|
||||||
|
can restore visibility without changing session identity.
|
||||||
|
|
||||||
|
Public listing responses contain bounded listing data only. They never contain
|
||||||
|
public/local endpoints, lease tokens, presence capabilities, fingerprints, store
|
||||||
|
keys, or canonical player identity.
|
||||||
|
|
||||||
|
## Failure semantics
|
||||||
|
|
||||||
|
- malformed or policy-invalid fields return a stable typed `InvalidRequest`;
|
||||||
|
- an unsupported gameplay protocol returns `IncompatibleProtocol`;
|
||||||
|
- missing/invalid publisher authentication returns `AuthenticationRequired`;
|
||||||
|
- cross-scope authorization returns `Forbidden` without resource disclosure;
|
||||||
|
- wrong owner/capability or expired state returns the tenant-hidden `NotFound`;
|
||||||
|
- idempotency reuse with changed input returns `Conflict`;
|
||||||
|
- publisher/global exhaustion returns `CapacityExceeded`; and
|
||||||
|
- drain or loss of atomic state returns `ServiceUnavailable` and authorizes no join.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- HTTP registration alone can never make a public session browseable.
|
||||||
|
- Plaintext session capabilities are returned to the intended host but are not
|
||||||
|
retained, logged, included in public listing DTOs, or exported as metrics.
|
||||||
|
- Re-registration after restart is the recovery path; there is no durable session
|
||||||
|
identity or gameplay state in Rendezvous.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# ADR 0006: bounded compatible session browser
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #8
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The public list endpoint requires game, environment, and exact gameplay protocol.
|
||||||
|
Region is optional, page size is 1–100, and callers may exclude sessions whose
|
||||||
|
advisory current-player count has reached the advertised maximum. Lists contain
|
||||||
|
public sessions only and only while both lease and authenticated host presence are
|
||||||
|
fresh. Unlisted sessions never appear in a list; they may be retrieved directly by
|
||||||
|
their 128-bit unguessable listing ID only when the caller also supplies the exact
|
||||||
|
game, environment, and protocol scope.
|
||||||
|
|
||||||
|
Results use ascending opaque listing ID as a deterministic keyset. A cursor carries
|
||||||
|
the last ID plus every compatibility/filter field, a five-minute expiry, and an
|
||||||
|
HMAC-SHA256 signature under a per-process key. Tampering, expiry, or reuse with a
|
||||||
|
different tenant/protocol/region/full filter returns `InvalidRequest`. Restart
|
||||||
|
rotates the key, matching the loss of ephemeral listings.
|
||||||
|
|
||||||
|
Pagination is a bounded live view, not a database snapshot. A record that remains
|
||||||
|
eligible and whose ID is greater than the cursor is returned exactly once. Records
|
||||||
|
removed or made stale disappear immediately. A record created after a page whose ID
|
||||||
|
sorts before that page's cursor is outside that traversal; callers refresh from the
|
||||||
|
first page to discover new sessions. This avoids skips or duplicates among stable
|
||||||
|
eligible records without retaining per-browser snapshot state.
|
||||||
|
|
||||||
|
The store reads at most page size plus one record. The service serializes against
|
||||||
|
the 256 KiB response ceiling and shortens a page before returning it when metadata
|
||||||
|
makes the requested count too large. A continuation cursor is emitted whenever an
|
||||||
|
extra or byte-trimmed record remains. All cursor, page, metadata, property, scalar,
|
||||||
|
and collection sizes are bounded before untrusted allocation can grow without a
|
||||||
|
ceiling.
|
||||||
|
|
||||||
|
Browser DTOs are fresh copies containing only opaque listing ID, exact compatibility,
|
||||||
|
region, visibility/trust presentation, advisory capacity, build/display labels, and
|
||||||
|
policy-validated string metadata. They contain no observed endpoint, lease,
|
||||||
|
capability, ticket, credential fingerprint, derivation salt, principal subject, or
|
||||||
|
store key. Metadata is display text: JSON encoding escapes markup, but game UI must
|
||||||
|
still render values as text and must never execute markup, interpret endpoints, or
|
||||||
|
use metadata for authorization.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Cross-game, cross-environment, incompatible, stale, revoked, expired, unlisted,
|
||||||
|
and optionally full sessions are removed before response construction.
|
||||||
|
- Direct unlisted lookup is suitable for an out-of-band invite carrying the opaque
|
||||||
|
ID; human join codes remain future work and require their own bounded abuse model.
|
||||||
|
- Host capacity remains advisory. The host makes the final admission decision.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR 0007: caller-owned .NET publisher and browser SDK
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #9
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The .NET client package exposes separate publisher and browser interfaces plus
|
||||||
|
concrete clients over a caller-supplied `HttpClient`. The caller owns that client,
|
||||||
|
its handler, base address, connection pool, proxy, and lifetime. SDK operations
|
||||||
|
dispose every request, response, and response body they create, but never dispose
|
||||||
|
the supplied client. The package targets `netstandard2.1`, depends only on the
|
||||||
|
wire-contract package and LiteNetLib, and contains no Godot types, global client,
|
||||||
|
service URL, publisher secret, or embedded game credential.
|
||||||
|
|
||||||
|
Every operation returns `RendezvousClientResult<T>` with a stable error code,
|
||||||
|
message, and optional retry guidance. Cancellation remains exceptional through
|
||||||
|
the caller's `CancellationToken`; transport failures become `ServiceUnavailable`.
|
||||||
|
Response bodies are streamed under the contract's 256 KiB browser ceiling before
|
||||||
|
deserialization. Invalid or oversized success bodies become `InternalError` and
|
||||||
|
never escape as partially trusted contract objects.
|
||||||
|
|
||||||
|
The SDK retries only operations whose duplicate execution is safe: scoped reads,
|
||||||
|
idempotency-keyed registration, lease renewal with the same lease token, complete
|
||||||
|
resource update, and lease-token deregistration. It honors bounded server retry
|
||||||
|
guidance and otherwise uses capped exponential backoff with jitter. Each retry
|
||||||
|
creates a fresh HTTP request while preserving the caller's registration
|
||||||
|
idempotency key. Configuration is copied on construction so later option mutation
|
||||||
|
cannot change an in-flight client's behavior.
|
||||||
|
|
||||||
|
`PublishedSession` holds the server-issued lease and presence capabilities needed
|
||||||
|
by the host. Its string representation always redacts them. Update requests are
|
||||||
|
copied before the lease token is attached, so the SDK never mutates caller-owned
|
||||||
|
DTOs. The browser exposes one-page calls and bounded cursor traversal; cursor
|
||||||
|
values remain opaque and caller requests remain unchanged.
|
||||||
|
|
||||||
|
Lease maintenance is explicit. Creating a `SessionLeaseMaintainer` starts no task;
|
||||||
|
the game chooses when to call `RunAsync`, owns cancellation, and awaits
|
||||||
|
`DisposeAsync`. The loop uses the latest server-provided renewal interval and
|
||||||
|
returns a distinct cancelled, disposed, lost-lease, or failed result. Terminal
|
||||||
|
authorization, expiry, and missing-lease responses also raise `LeaseLost` so the
|
||||||
|
host can stop advertising or re-register deliberately.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- SpaceGame and Unscouted can inject the publisher/browser interfaces in tests
|
||||||
|
without an engine runtime or real network.
|
||||||
|
- Games must configure an absolute `HttpClient.BaseAddress` (or equivalent
|
||||||
|
handler routing), obtain publisher credentials from their deployment boundary,
|
||||||
|
and explicitly run and dispose lease maintenance.
|
||||||
|
- The versioned client public-API snapshot and live-server integration tests fail
|
||||||
|
together when SDK and HTTP contracts drift.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR 0008: scoped join attempts and one-time connection tickets
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #10
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Join creation is an unauthenticated public operation because v1 does not treat a
|
||||||
|
Rendezvous caller as game identity. The HTTP source address is normalized and
|
||||||
|
converted to a process-keyed opaque subject for idempotency and bounded policy
|
||||||
|
accounting; raw addresses and the derived subject are never returned or logged.
|
||||||
|
A successful request means only that this network client may try to connect to
|
||||||
|
this active session. It does not reserve capacity or grant gameplay admission.
|
||||||
|
|
||||||
|
Creation validates the v1 contract, caller idempotency key, enabled tenant policy,
|
||||||
|
exact gameplay protocol, listing scope, live lease, and fresh authenticated host
|
||||||
|
presence in one atomic store operation. A listing advertised as full remains
|
||||||
|
joinable because its player count is advisory and the game host owns the final
|
||||||
|
capacity, identity, ban, and admission decision.
|
||||||
|
|
||||||
|
Each attempt derives independent host-punch, client-punch, and connection-ticket
|
||||||
|
credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC
|
||||||
|
key, the client subject, the complete canonical request fingerprint, a fresh salt,
|
||||||
|
and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
|
||||||
|
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
|
||||||
|
256-character NAT token ceiling. The connection ticket uses half of that payload
|
||||||
|
for its attempt ID and half for an independently derived 128-bit authenticator, so
|
||||||
|
the SDK can correlate concurrent introductions without increasing UDP response
|
||||||
|
size. State retains keyed credential fingerprints, derivation inputs, and salt—not
|
||||||
|
issued plaintext. All diagnostic string representations redact credentials and
|
||||||
|
derivation material.
|
||||||
|
|
||||||
|
The client receives only its punch capability. A host polls its own listing with
|
||||||
|
the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
|
||||||
|
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
|
||||||
|
identical join request returns the same live attempt; changing the request under
|
||||||
|
the same owner/key conflicts. A client may cancel with its punch capability in
|
||||||
|
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically marks the attempt
|
||||||
|
and retains a bounded tombstone until its original expiry. Host polling returns
|
||||||
|
that tombstone so a coordinator can revoke any local ticket authorization, while
|
||||||
|
endpoint binding, introduction, ticket issuance, and ticket consumption all
|
||||||
|
reject the cancelled attempt. Listing deletion, expiry, revocation, or process
|
||||||
|
restart removes every associated attempt and credential fingerprint.
|
||||||
|
|
||||||
|
Endpoint binding remains role- and capability-specific. The first endpoint
|
||||||
|
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
|
||||||
|
endpoint or role substitution is rejected. An introduction is consumable once
|
||||||
|
only after both roles bind, so concurrent attempts for the same listing cannot
|
||||||
|
cross-wire.
|
||||||
|
|
||||||
|
The connection ticket is distinct from both punch capabilities and is reproduced
|
||||||
|
only after introduction succeeds. Its window begins at that moment and lasts at
|
||||||
|
most 20 seconds without outliving the 30-second attempt. The server has an atomic
|
||||||
|
fingerprint-consumption seam for mediator tests and revocation. On the game host,
|
||||||
|
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
|
||||||
|
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/
|
||||||
|
revoked/replayed tickets, and zeroes retained digests and key material on disposal.
|
||||||
|
Issue #11 carries the fixed-size ticket in the authenticated introduction. Issue
|
||||||
|
#12 extracts its embedded attempt ID, bounds the host's local authorization window
|
||||||
|
by both the host-polled attempt expiry and the configured ticket lifetime, then
|
||||||
|
wires one-time consumption into the caller-owned coordinator. Both peers receive
|
||||||
|
a digest of the exact expected ticket over HTTP and reject any syntactically valid
|
||||||
|
but unauthenticated introduction token. Embedding the ID prevents concurrent or
|
||||||
|
late introductions from cross-binding a valid ticket while preserving the
|
||||||
|
mediator's 2.0 response-byte amplification ceiling.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A join attempt is transport authorization, never proof of player identity or a
|
||||||
|
game slot.
|
||||||
|
- Network-address-derived subjects are process-local abuse/idempotency scopes,
|
||||||
|
not stable user identifiers; stronger authenticated player scopes require a
|
||||||
|
future game-owned identity contract.
|
||||||
|
- Cancellation after a ticket has reached a host must also revoke that host's
|
||||||
|
local validator entry; coordinator wiring owns that race in issue #12.
|
||||||
|
- Capability and ticket plaintext never enter browser results, state snapshots,
|
||||||
|
logs, metrics, or generated string representations.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0009: authenticated bounded LiteNetLib NAT mediator
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #11
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The server owns one LiteNetLib `NetManager` and its `NatPunchModule` on the
|
||||||
|
configured UDP endpoint. It runs in manual mode with a configured maximum number
|
||||||
|
of datagrams per poll and a short caller-owned poll interval. LiteNetLib events
|
||||||
|
are unsynchronized so authenticated requests are processed immediately on that
|
||||||
|
single polling path rather than accumulated in an unbounded event queue. The
|
||||||
|
mediator never accepts a LiteNetLib gameplay connection or handles application
|
||||||
|
payloads.
|
||||||
|
|
||||||
|
The packet layer also consumes the frozen v1 presence envelope on the same
|
||||||
|
socket. Native NAT requests use a canonical fixed-size 192-character token that
|
||||||
|
binds a role (`HostPresence`, attempt `Host`, or attempt `Client`), mediation
|
||||||
|
handle, and the already-issued capability. Both transports enter one processor
|
||||||
|
and the same atomic store operations. No transport-supplied public address is
|
||||||
|
trusted; the socket source is authoritative.
|
||||||
|
|
||||||
|
LiteNetLib's native NAT packet family also contains introduction-response and
|
||||||
|
punch frames that are appropriate for peers but unsafe on a public mediator: a
|
||||||
|
forged response can name arbitrary destinations. The packet layer therefore
|
||||||
|
decodes only the pinned `NatIntroduceRequest` wire shape and consumes every
|
||||||
|
inbound packet before `NatPunchModule` sees it. The module is outbound-only and
|
||||||
|
may send introductions solely from a completed authorized plan.
|
||||||
|
|
||||||
|
Listing presence refreshes authorize no response. Attempt contributions bind the
|
||||||
|
first observed endpoint for exactly one capability role. Exact duplicates are
|
||||||
|
idempotent; a different endpoint, the opposite role, an expired/cancelled
|
||||||
|
attempt, or a stale listing presence cannot replace it. The introduction is
|
||||||
|
consumed atomically only after both roles bind and their observed address
|
||||||
|
families match, preventing concurrent attempts for one listing from cross-wiring.
|
||||||
|
|
||||||
|
A reported local candidate is eligible only when it is RFC 1918 IPv4 or IPv6
|
||||||
|
unique-local unicast, matches the observed family, and both peers have the same
|
||||||
|
observed public address. Otherwise `NatIntroduce` receives the observed public
|
||||||
|
endpoint in the local slot. Loopback, link-local, multicast, unspecified,
|
||||||
|
documentation IPv6, global-address claims, and cross-family claims are never
|
||||||
|
disclosed as local targets. IPv4 is required; observed global IPv6 can be used
|
||||||
|
when both peers contribute IPv6, without claiming guaranteed IPv6 NAT traversal.
|
||||||
|
|
||||||
|
The introduction carries only the distinct connection ticket and is emitted at
|
||||||
|
most once to each verified observed endpoint. The fixed authenticated native
|
||||||
|
request and bounded frozen envelope keep the combined response bytes within the
|
||||||
|
2.0 verified amplification budget; unauthenticated inputs receive zero bytes.
|
||||||
|
Malformed, truncated, oversized, spoofed, or unrelated LiteNetLib packets do not
|
||||||
|
grow Rendezvous state. Raw endpoints and credentials are never logged or exposed
|
||||||
|
through diagnostic string representations.
|
||||||
|
|
||||||
|
Frozen IPv6 listing-presence refresh remains valid because it emits no response.
|
||||||
|
IPv6 attempt roles require the fixed-size native LiteNetLib request; accepting the
|
||||||
|
short frozen envelope would exceed the 2.0 byte budget for two IPv6 introduction
|
||||||
|
frames. The required IPv4 listen address and optional IPv6 listen address are
|
||||||
|
configured separately so enabling one family never widens the other family to a
|
||||||
|
wildcard bind.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Hosts refresh listing presence and answer invitations from their actual
|
||||||
|
gameplay socket; a separate mediator socket would observe the wrong mapping.
|
||||||
|
- Caller-owned SDK coordination in #12 must poll the host invitation endpoint,
|
||||||
|
send the corresponding native role token, and consume the returned ticket.
|
||||||
|
- UDP loss can prevent traversal, but it cannot cause an arbitrary destination,
|
||||||
|
replay, role substitution, or cross-attempt introduction.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #13
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A connection can stop in the directory, authorization, mediation, NAT traversal,
|
||||||
|
or direct-connection phase. Those failures have different authorities: an HTTP
|
||||||
|
response can authoritatively reject a join, the SDK can observe a local timeout,
|
||||||
|
and only the remote host can reject a direct connection. Treating all of them as
|
||||||
|
one message or generic timeout would make player guidance, retry policy, tests,
|
||||||
|
and operational measurements unreliable.
|
||||||
|
|
||||||
|
UDP loss, service silence, cancellation, and late LiteNetLib callbacks also make
|
||||||
|
completion races unavoidable. Games need one terminal result and bounded work,
|
||||||
|
not a sequence of contradictory callbacks. Direct traversal cannot be guaranteed,
|
||||||
|
but v1 has no gameplay relay and must not imply otherwise.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Closed typed outcome model
|
||||||
|
|
||||||
|
`ConnectionOutcomeKind` is the stable wire-level terminal set: connected,
|
||||||
|
cancelled, directory not found, attempt expired, incompatible protocol,
|
||||||
|
unauthorized, rate limited, no host presence, service unavailable or rejected,
|
||||||
|
mediator unavailable, punch timeout, direct-connect timeout, host rejection,
|
||||||
|
transport error, manager stopped, and disposed.
|
||||||
|
|
||||||
|
The already-frozen v1 members `TimedOut`, `StaleHost`, `TransportFailed`, and
|
||||||
|
`FallbackOffered` retain their original numeric values for source and wire
|
||||||
|
compatibility. New SDK code never emits them. The report service accepts them,
|
||||||
|
normalizes the first three to their precise modern equivalents, and does not let
|
||||||
|
legacy compatibility weaken the typed coordinator result.
|
||||||
|
|
||||||
|
The client adds `RendezvousConnectionOutcomeSource`, failure category, and phase.
|
||||||
|
These fields preserve authority instead of guessing from text:
|
||||||
|
|
||||||
|
- `RendezvousService` is used only for an HTTP decision or bounded service
|
||||||
|
silence. Its optional `ServiceError` retains the stable service error code.
|
||||||
|
- `LocalTraversal` reports local punch, direct-connect, and transport
|
||||||
|
observations.
|
||||||
|
- `RemoteHost` reports an explicit direct-connection rejection.
|
||||||
|
- `Caller` and `Lifecycle` distinguish cancellation from manager shutdown or
|
||||||
|
disposal.
|
||||||
|
|
||||||
|
Messages remain diagnostic and are never parsed into outcomes. A successful NAT
|
||||||
|
introduction is only a transition to direct connection; `Connected` is emitted
|
||||||
|
only after LiteNetLib reports the authenticated peer connected.
|
||||||
|
|
||||||
|
Join issuance is exposed as `RendezvousConnectionStartResult`, containing exactly
|
||||||
|
one issued attempt or one terminal service outcome. Once an attempt is issued,
|
||||||
|
the coordinator owns its local terminal outcome. Completion is exactly once;
|
||||||
|
terminal paths release SDK subscriptions so late introductions, peer callbacks,
|
||||||
|
network errors, cancellation, and polling are inert.
|
||||||
|
|
||||||
|
### Bounded phases and retries
|
||||||
|
|
||||||
|
Each HTTP try has a five-second default silence budget, configurable from above
|
||||||
|
zero through thirty seconds. Only safe operations use the existing bounded retry
|
||||||
|
policy, honoring caller cancellation and server retry guidance. Exhausting that
|
||||||
|
budget returns `ServiceUnavailable`; it never waits indefinitely.
|
||||||
|
|
||||||
|
Traversal has independent defaults: ten seconds for punch/mediation and five
|
||||||
|
seconds for the direct connection. Both are configurable up to thirty seconds.
|
||||||
|
Local budgets, retry schedules, and elapsed duration use monotonic time, so a
|
||||||
|
wall-clock correction cannot extend them or produce a negative duration. The
|
||||||
|
signed attempt expiry is converted to an additional monotonic upper bound when
|
||||||
|
the attempt is received. Punch retries retain
|
||||||
|
their bounded request count and exponential backoff; crossing a phase deadline
|
||||||
|
completes exactly once even if a delayed packet later arrives. Tests use an
|
||||||
|
injected clock and do not depend on wall-clock sleeps.
|
||||||
|
|
||||||
|
### Explicit dedicated fallback handoff
|
||||||
|
|
||||||
|
A publisher may attach one validated dedicated endpoint to registration or
|
||||||
|
update only when the tenant's provisioned fallback policy allows it. The server
|
||||||
|
copies that endpoint into browser and issued-attempt contracts.
|
||||||
|
The client coordinator defensively copies it into every terminal outcome; a game
|
||||||
|
may override it locally through `DedicatedFallbackOverride`.
|
||||||
|
|
||||||
|
The SDK never opens, dials, reserves, probes, or authenticates the fallback. The
|
||||||
|
game decides whether the outcome permits fallback, presents any player choice,
|
||||||
|
and connects through its own gameplay transport and admission rules. Absence of
|
||||||
|
an endpoint is an honest no-fallback result. Gameplay relay is absent from v1.
|
||||||
|
|
||||||
|
### Privacy-safe optional reporting
|
||||||
|
|
||||||
|
After an issued attempt completes, the game may explicitly report its outcome
|
||||||
|
with the short-lived client punch capability. Reporting is authenticated and
|
||||||
|
idempotent: an exact repeat succeeds as a duplicate, while a conflicting repeat
|
||||||
|
is rejected. Reports contain only an allowlisted outcome enum and one coarse
|
||||||
|
elapsed bucket (`<1s`, `1–5s`, `5–15s`, `15–30s`, or `30s+`). They contain no
|
||||||
|
diagnostic message, exact duration, endpoint, metadata, player identifier, or
|
||||||
|
credential.
|
||||||
|
|
||||||
|
Frozen v1 DTOs still expose `elapsedMilliseconds` and `diagnosticCode`. They are
|
||||||
|
deprecated compatibility inputs: the current SDK omits them, the service
|
||||||
|
immediately buckets legacy elapsed time, and neither exact timing nor diagnostic
|
||||||
|
text is retained, logged, or used as a metric dimension.
|
||||||
|
|
||||||
|
The store retains a bounded capability-fingerprint tombstone long enough to
|
||||||
|
accept a report after the live attempt expires. Metrics count the first accepted
|
||||||
|
outcome only and use only outcome plus elapsed bucket as dimensions. Service
|
||||||
|
issuance failures cannot be reported because no attempt capability was issued.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Player-facing UI can map stable outcome/category pairs to localized guidance
|
||||||
|
without exposing diagnostic strings.
|
||||||
|
- Service rejection, remote-host rejection, and local observation remain
|
||||||
|
distinguishable for retry and support decisions.
|
||||||
|
- Games own fallback policy and gameplay admission; Rendezvous does not claim a
|
||||||
|
guaranteed connection path.
|
||||||
|
- Outcome additions are contract changes and require OpenAPI, serialization,
|
||||||
|
public API, fake-clock, late-event, and idempotency coverage.
|
||||||
@@ -6,6 +6,13 @@ 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 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 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 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
|
||||||
|
- [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md)
|
||||||
|
- [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md)
|
||||||
|
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md)
|
||||||
|
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
|
||||||
|
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
|
||||||
|
- [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md)
|
||||||
|
- [ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback](0010-typed-connection-outcomes-and-fallback.md)
|
||||||
- [Threat model](../security/threat-model.md)
|
- [Threat model](../security/threat-model.md)
|
||||||
- [Security promise and test matrix](../security/control-matrix.md)
|
- [Security promise and test matrix](../security/control-matrix.md)
|
||||||
- [Versioned HTTP and UDP contracts](../contracts/README.md)
|
- [Versioned HTTP and UDP contracts](../contracts/README.md)
|
||||||
|
|||||||
@@ -33,15 +33,14 @@ the same value as a required query parameter.
|
|||||||
| `GET` | `/v1/sessions` | Browse compatible public sessions. |
|
| `GET` | `/v1/sessions` | Browse compatible public sessions. |
|
||||||
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
|
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
|
||||||
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
|
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
|
||||||
|
| `DELETE` | `/v1/join-attempts/{attemptId}` | Cancel an attempt using its client punch capability. |
|
||||||
| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
|
| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
|
||||||
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
|
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
|
||||||
| `GET` | `/health/live` | Report that the HTTP process is alive. |
|
| `GET` | `/health/live` | Report that the HTTP process is alive. |
|
||||||
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
|
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
|
||||||
|
|
||||||
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
|
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
|
||||||
shape reference for parameters, bodies, and responses. Contract-only endpoints
|
shape reference for parameters, bodies, and responses.
|
||||||
return `501` until their behavior is implemented by the subsequent directory,
|
|
||||||
lease, and join-orchestration issues.
|
|
||||||
|
|
||||||
Host polling sends its reusable lease credential in
|
Host polling sends its reusable lease credential in
|
||||||
`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
|
`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
|
||||||
@@ -49,6 +48,29 @@ for mutation operations are carried in their request bodies. Public browser
|
|||||||
responses contain no IP endpoints, lease tokens, punch capabilities, connection
|
responses contain no IP endpoints, lease tokens, punch capabilities, connection
|
||||||
tickets, player identifiers, or gameplay state.
|
tickets, player identifiers, or gameplay state.
|
||||||
|
|
||||||
|
Attempt cancellation sends the short-lived client punch capability in
|
||||||
|
`X-Rendezvous-Client-Punch-Capability`. Join creation uses the observed HTTP
|
||||||
|
source only for a process-keyed, short-lived idempotency/abuse scope; this is not
|
||||||
|
player authentication and is never returned to callers.
|
||||||
|
|
||||||
|
Outcome reporting uses that same short-lived capability. It accepts only outcomes
|
||||||
|
for an issued attempt and carries one stable outcome enum plus one coarse elapsed
|
||||||
|
bucket. Exact duplicate reports are idempotent; conflicting repeats fail. Reports
|
||||||
|
never carry exact timing, diagnostics, endpoints, metadata, player identifiers,
|
||||||
|
or credentials.
|
||||||
|
|
||||||
|
The frozen v1 .NET request also retains deprecated `elapsedMilliseconds` and
|
||||||
|
`diagnosticCode` properties for source/wire compatibility. Current clients omit
|
||||||
|
them. If a legacy client supplies them, the server immediately converts elapsed
|
||||||
|
milliseconds to the coarse bucket and discards diagnostic text; neither value is
|
||||||
|
retained or used as a metric dimension.
|
||||||
|
|
||||||
|
Registration and update may include one validated `dedicatedFallback`. The
|
||||||
|
endpoint must be enabled by the tenant's provisioned fallback policy, is visible
|
||||||
|
browser data, and is copied into subsequently issued attempts.
|
||||||
|
It is a handoff for caller-owned policy: neither the HTTP service nor the SDK
|
||||||
|
automatically connects to it. V1 provides no gameplay relay.
|
||||||
|
|
||||||
## Idempotency, cursors, and retries
|
## Idempotency, cursors, and retries
|
||||||
|
|
||||||
Registration and join creation require a caller-generated visible-ASCII
|
Registration and join creation require a caller-generated visible-ASCII
|
||||||
@@ -95,9 +117,9 @@ must not be parsed. Secrets and raw credentials are never echoed.
|
|||||||
| 401 | `authenticationRequired` |
|
| 401 | `authenticationRequired` |
|
||||||
| 403 | `forbidden` |
|
| 403 | `forbidden` |
|
||||||
| 404 | `notFound` |
|
| 404 | `notFound` |
|
||||||
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` |
|
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected` |
|
||||||
| 410 | `expired`, `staleHost` |
|
| 410 | `expired`, `staleHost` |
|
||||||
| 429 | `rateLimited` (with retry guidance when known) |
|
| 429 | `rateLimited`, `capacityExceeded` (with retry guidance when known) |
|
||||||
| 503 | `serviceUnavailable` (with retry guidance when known) |
|
| 503 | `serviceUnavailable` (with retry guidance when known) |
|
||||||
| 500 | `internalError` |
|
| 500 | `internalError` |
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# UDP presence contract v1
|
# UDP presence and NAT-punch contract v1
|
||||||
|
|
||||||
Tracking: #4
|
Tracking: #4, #11
|
||||||
|
|
||||||
The UDP mediator accepts a single bounded presence envelope from a host or
|
The UDP mediator accepts the frozen bounded presence envelope below and native
|
||||||
client. It associates the authenticated mediation handle with the packet's
|
LiteNetLib NAT-introduction requests. Both forms associate an authenticated
|
||||||
observed public source endpoint and the sender's reported local endpoint. It
|
mediation handle with the packet's observed public source endpoint and the
|
||||||
does not carry gameplay packets.
|
sender's reported local endpoint. Neither form carries gameplay packets.
|
||||||
|
|
||||||
All multi-byte integers use network byte order. UUID bytes use the canonical
|
All multi-byte integers use network byte order. UUID bytes use the canonical
|
||||||
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
|
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
|
||||||
@@ -49,5 +49,48 @@ Capabilities are short-lived, single-purpose, scoped to one mediation handle,
|
|||||||
and compared without exposing them in logs. A valid-looking packet does not
|
and compared without exposing them in logs. A valid-looking packet does not
|
||||||
prove authorization until the capability is checked. Invalid packets receive
|
prove authorization until the capability is checked. Invalid packets receive
|
||||||
no UDP response, preventing the mediator from becoming an amplification oracle.
|
no UDP response, preventing the mediator from becoming an amplification oracle.
|
||||||
Replay, expiry, pairing, and rate-limit policy are defined by later mediator
|
For the frozen envelope, `HostPresence` is resolved against either the listing's
|
||||||
issues; the v1 envelope deliberately leaves no unbounded or reflected payload.
|
host-presence capability or an attempt's host-role capability. `ClientPresence`
|
||||||
|
is resolved only against the attempt's client-role capability. Handles are
|
||||||
|
globally distinct in the active store, so this does not permit role confusion.
|
||||||
|
|
||||||
|
## Native LiteNetLib request token
|
||||||
|
|
||||||
|
A game using LiteNetLib sends `NatPunchModule.SendNatIntroduceRequest` from its
|
||||||
|
gameplay `NetManager`. The `additionalInfo` value is produced by
|
||||||
|
`NatPunchRequestTokenCodec` and is exactly 192 ASCII characters:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rv1:<role>:<32 lowercase handle hex>:<43-character capability><dot padding>
|
||||||
|
```
|
||||||
|
|
||||||
|
`role` is `p` for listing host-presence refresh, `h` for the host side of a join
|
||||||
|
attempt, or `c` for its client side. Padding is canonical and leaves the token
|
||||||
|
below LiteNetLib's 256-character ceiling. Its fixed size also ensures that the
|
||||||
|
two authenticated introduction responses remain within the 2.0 response-byte
|
||||||
|
budget. Tokens with a wrong length, role, handle, capability, or padding receive
|
||||||
|
no response.
|
||||||
|
|
||||||
|
The mediator runs LiteNetLib in bounded manual-poll mode. Its packet layer admits
|
||||||
|
only the pinned native `NatIntroduceRequest` frame, consumes every inbound frame
|
||||||
|
before LiteNetLib can act on it, and uses `NatPunchModule` only to emit authorized
|
||||||
|
introductions. Native and frozen v1 inputs reach the same atomic role/capability
|
||||||
|
checks. Only the packet source is
|
||||||
|
used as the public endpoint. A claimed private candidate is retained only when
|
||||||
|
it is private unicast, matches the observed address family, and both authorized
|
||||||
|
peers were observed behind the same public address; otherwise the observed
|
||||||
|
public endpoint is substituted. IPv4 punching is required. IPv6 sources must be
|
||||||
|
observed global unicast and both roles must use IPv6; IPv6 NAT traversal remains
|
||||||
|
best-effort rather than a v1 release requirement.
|
||||||
|
|
||||||
|
The second valid contribution atomically consumes the introduction and starts
|
||||||
|
the connection-ticket lifetime. `NatIntroduce` is called once with the distinct
|
||||||
|
43-character connection ticket. Reordered and exact duplicate requests are
|
||||||
|
idempotent. Endpoint substitution, cross-role use, stale host presence, expired
|
||||||
|
or cancelled attempts, malformed packets, and gameplay payloads produce no
|
||||||
|
introduction and create no mediator queue or endpoint state.
|
||||||
|
|
||||||
|
Frozen envelopes may refresh listing presence over IPv6 because that operation
|
||||||
|
has no response. IPv6 attempt contributions must use the fixed-size native token;
|
||||||
|
the shorter frozen IPv6 envelope cannot fund two IPv6 introduction frames within
|
||||||
|
the 2.0 response-byte ceiling and is therefore dropped without response.
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Diagnostic TestClient integration guide
|
||||||
|
|
||||||
|
Tracking: #25
|
||||||
|
|
||||||
|
`FinalFactory.Rendezvous.TestClient` is the smallest supported public-SDK consumer.
|
||||||
|
It exists for integration development, CI smoke checks, deployment verification,
|
||||||
|
and operator diagnosis. It is intentionally not a production game client, game
|
||||||
|
server, matchmaking UI, or relay.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Start a configured Rendezvous service and note both its HTTP base URL and UDP
|
||||||
|
mediator endpoint. The host needs a tenant-scoped publisher credential from the
|
||||||
|
deployment secret boundary. Put it in an environment variable and pass only that
|
||||||
|
variable's name when the default is unsuitable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<deployment-supplied value>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Never put the credential in a command argument, URL, checked-in configuration,
|
||||||
|
shell trace, or captured test fixture. The development server's signing material
|
||||||
|
is process-ephemeral; credentials from a prior development process are invalid.
|
||||||
|
|
||||||
|
## Manual three-terminal flow
|
||||||
|
|
||||||
|
Start the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||||
|
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
||||||
|
--game space-game --environment development --region local --protocol 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Browse from another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||||
|
browse --service http://127.0.0.1:5000/ \
|
||||||
|
--game space-game --environment development --region local --protocol 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Join from a third terminal. Omit `--listing` for an interactive choice:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||||
|
join --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
||||||
|
--game space-game --environment development --region local --protocol 1 \
|
||||||
|
--listing 00000000-0000-0000-0000-000000000000
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the sample UUID with the public listing ID printed by host or browse.
|
||||||
|
Host and join each create one caller-owned LiteNetLib manager. That same socket
|
||||||
|
sends presence/punch traffic, establishes the authenticated direct connection,
|
||||||
|
and carries the ping/echo/ack/completion payload. The final completion confirms
|
||||||
|
that the host received the reliable acknowledgement; none of this traffic passes through the HTTP
|
||||||
|
service or UDP mediator.
|
||||||
|
|
||||||
|
## CI and deployment smoke flow
|
||||||
|
|
||||||
|
Use `--script --json`, set `--listing` when deterministic selection matters, and
|
||||||
|
check the documented process exit code. `--timeout-seconds` bounds each startup,
|
||||||
|
traversal, or direct-traffic stage; a script host also uses it as its total runtime
|
||||||
|
unless `--run-seconds` is explicit. A host can add `--exit-after-echo` so it
|
||||||
|
terminates after the joining peer acknowledges direct traffic and receives the
|
||||||
|
host's completion confirmation. Every wait is
|
||||||
|
bounded by coordinator state and `--timeout-seconds`; no orchestration should use
|
||||||
|
an unbounded sleep.
|
||||||
|
|
||||||
|
The normal test suite contains a real process gate that starts the built Server,
|
||||||
|
host TestClient, and join TestClient, waits for readiness and versioned events,
|
||||||
|
and verifies direct traffic, cleanup, JSON shape, and secret canaries. Process
|
||||||
|
trees are force-terminated in the test cleanup path if normal shutdown fails.
|
||||||
|
|
||||||
|
Useful success events are:
|
||||||
|
|
||||||
|
- `host.registered`, `host.ready`, `host.direct-traffic`, and `host.deregistered`;
|
||||||
|
- `browse.completed` and `browse.session`; and
|
||||||
|
- `join.connected`, `join.direct-traffic`, and `join.outcome-report`.
|
||||||
|
|
||||||
|
Failure events preserve stable typed phases and outcomes. When a terminal outcome
|
||||||
|
contains a configured dedicated endpoint, `join.fallback` reports `available`
|
||||||
|
with endpoint type `dedicated`; no raw address is printed and no fallback is
|
||||||
|
started implicitly.
|
||||||
|
|
||||||
|
## What the proof does and does not establish
|
||||||
|
|
||||||
|
The deterministic loopback test proves the complete service/host/client protocol,
|
||||||
|
ticket admission, and peer-to-peer payload path. Loopback is not evidence that all
|
||||||
|
consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths,
|
||||||
|
or platform policies permit hole punching. Same-LAN, separated observed endpoints,
|
||||||
|
network namespaces/containers, mediator restart, and adverse topology coverage
|
||||||
|
belong to the topology harness tracked by #14. Production rollout still requires
|
||||||
|
tests from representative networks and a game-owned fallback policy.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum RendezvousConnectionOutcomeSource
|
||||||
|
{
|
||||||
|
RendezvousService = 1,
|
||||||
|
LocalTraversal = 2,
|
||||||
|
RemoteHost = 3,
|
||||||
|
Caller = 4,
|
||||||
|
Lifecycle = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RendezvousConnectionFailureCategory
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Directory = 1,
|
||||||
|
Compatibility = 2,
|
||||||
|
Authorization = 3,
|
||||||
|
Capacity = 4,
|
||||||
|
HostPresence = 5,
|
||||||
|
Service = 6,
|
||||||
|
Mediation = 7,
|
||||||
|
NatTraversal = 8,
|
||||||
|
DirectConnection = 9,
|
||||||
|
Lifecycle = 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RendezvousConnectionPhase
|
||||||
|
{
|
||||||
|
Directory = 1,
|
||||||
|
Authorization = 2,
|
||||||
|
Mediation = 3,
|
||||||
|
NatTraversal = 4,
|
||||||
|
DirectConnection = 5,
|
||||||
|
Complete = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousConnectionOutcome
|
||||||
|
{
|
||||||
|
private readonly NetworkEndpoint? _dedicatedFallback;
|
||||||
|
|
||||||
|
private RendezvousConnectionOutcome(
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
TimeSpan elapsed,
|
||||||
|
RendezvousErrorCode? serviceError,
|
||||||
|
NetworkEndpoint? dedicatedFallback,
|
||||||
|
NetPeer? peer)
|
||||||
|
{
|
||||||
|
if (elapsed < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(elapsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dedicatedFallback is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
Kind = kind;
|
||||||
|
Source = source;
|
||||||
|
Category = category;
|
||||||
|
Phase = phase;
|
||||||
|
Elapsed = elapsed;
|
||||||
|
ServiceError = serviceError;
|
||||||
|
_dedicatedFallback = RendezvousEndpoint.Copy(dedicatedFallback);
|
||||||
|
Peer = peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConnectionOutcomeKind Kind { get; }
|
||||||
|
public RendezvousConnectionOutcomeSource Source { get; }
|
||||||
|
public RendezvousConnectionFailureCategory Category { get; }
|
||||||
|
public RendezvousConnectionPhase Phase { get; }
|
||||||
|
public TimeSpan Elapsed { get; }
|
||||||
|
public RendezvousErrorCode? ServiceError { get; }
|
||||||
|
public NetworkEndpoint? DedicatedFallback => RendezvousEndpoint.Copy(_dedicatedFallback);
|
||||||
|
public NetPeer? Peer { get; }
|
||||||
|
public bool IsSuccess => Kind == ConnectionOutcomeKind.Connected;
|
||||||
|
public bool HasDedicatedFallback => _dedicatedFallback is not null;
|
||||||
|
|
||||||
|
public static RendezvousConnectionOutcome FromServiceError(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
TimeSpan elapsed,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null)
|
||||||
|
{
|
||||||
|
if (error == RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A service failure outcome requires an error.", nameof(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
(ConnectionOutcomeKind kind, RendezvousConnectionFailureCategory category, RendezvousConnectionPhase phase) =
|
||||||
|
error switch
|
||||||
|
{
|
||||||
|
RendezvousErrorCode.NotFound => (
|
||||||
|
ConnectionOutcomeKind.DirectoryNotFound,
|
||||||
|
RendezvousConnectionFailureCategory.Directory,
|
||||||
|
RendezvousConnectionPhase.Directory),
|
||||||
|
RendezvousErrorCode.Expired => (
|
||||||
|
ConnectionOutcomeKind.AttemptExpired,
|
||||||
|
RendezvousConnectionFailureCategory.Authorization,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
RendezvousErrorCode.IncompatibleProtocol => (
|
||||||
|
ConnectionOutcomeKind.IncompatibleProtocol,
|
||||||
|
RendezvousConnectionFailureCategory.Compatibility,
|
||||||
|
RendezvousConnectionPhase.Directory),
|
||||||
|
RendezvousErrorCode.AuthenticationRequired
|
||||||
|
or RendezvousErrorCode.Forbidden
|
||||||
|
or RendezvousErrorCode.ReplayRejected => (
|
||||||
|
ConnectionOutcomeKind.Unauthorized,
|
||||||
|
RendezvousConnectionFailureCategory.Authorization,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
RendezvousErrorCode.RateLimited
|
||||||
|
or RendezvousErrorCode.CapacityExceeded => (
|
||||||
|
ConnectionOutcomeKind.RateLimited,
|
||||||
|
RendezvousConnectionFailureCategory.Capacity,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
RendezvousErrorCode.StaleHost => (
|
||||||
|
ConnectionOutcomeKind.NoHostPresence,
|
||||||
|
RendezvousConnectionFailureCategory.HostPresence,
|
||||||
|
RendezvousConnectionPhase.Mediation),
|
||||||
|
RendezvousErrorCode.ServiceUnavailable => (
|
||||||
|
ConnectionOutcomeKind.ServiceUnavailable,
|
||||||
|
RendezvousConnectionFailureCategory.Service,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
_ => (
|
||||||
|
ConnectionOutcomeKind.ServiceRejected,
|
||||||
|
RendezvousConnectionFailureCategory.Service,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
};
|
||||||
|
return new(
|
||||||
|
kind,
|
||||||
|
RendezvousConnectionOutcomeSource.RendezvousService,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
elapsed,
|
||||||
|
error,
|
||||||
|
dedicatedFallback,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConnectionElapsedBucket BucketElapsed(TimeSpan elapsed)
|
||||||
|
{
|
||||||
|
if (elapsed < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(elapsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return elapsed.TotalSeconds switch
|
||||||
|
{
|
||||||
|
< 1 => ConnectionElapsedBucket.UnderOneSecond,
|
||||||
|
< 5 => ConnectionElapsedBucket.OneToFiveSeconds,
|
||||||
|
< 15 => ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||||
|
< 30 => ConnectionElapsedBucket.FifteenToThirtySeconds,
|
||||||
|
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[RendezvousConnectionOutcome {Kind}; {Source}; credentials redacted]";
|
||||||
|
|
||||||
|
internal static RendezvousConnectionOutcome Create(
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
TimeSpan elapsed,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null,
|
||||||
|
NetPeer? peer = null) => new(
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
elapsed,
|
||||||
|
null,
|
||||||
|
dedicatedFallback,
|
||||||
|
peer);
|
||||||
|
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousConnectionStartResult
|
||||||
|
{
|
||||||
|
internal RendezvousConnectionStartResult(
|
||||||
|
CreateJoinAttemptResponse? attempt,
|
||||||
|
RendezvousConnectionOutcome? outcome)
|
||||||
|
{
|
||||||
|
if ((attempt is null) == (outcome is null))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"A connection start result requires exactly one attempt or terminal outcome.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Attempt = attempt;
|
||||||
|
Outcome = outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreateJoinAttemptResponse? Attempt { get; }
|
||||||
|
public RendezvousConnectionOutcome? Outcome { get; }
|
||||||
|
public bool IsReadyForTraversal => Attempt is not null;
|
||||||
|
public bool IsCompleted => Outcome is not null;
|
||||||
|
|
||||||
|
public static RendezvousConnectionStartResult ReadyForTraversal(
|
||||||
|
CreateJoinAttemptResponse attempt) => new(
|
||||||
|
attempt ?? throw new ArgumentNullException(nameof(attempt)),
|
||||||
|
null);
|
||||||
|
|
||||||
|
public static RendezvousConnectionStartResult Completed(
|
||||||
|
RendezvousConnectionOutcome outcome) => new(
|
||||||
|
null,
|
||||||
|
outcome ?? throw new ArgumentNullException(nameof(outcome)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum ConnectionTicketConsumptionResult
|
||||||
|
{
|
||||||
|
Accepted = 1,
|
||||||
|
NotFound = 2,
|
||||||
|
Expired = 3,
|
||||||
|
Rejected = 4,
|
||||||
|
AlreadyConsumed = 5,
|
||||||
|
Revoked = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ConnectionTicketValidator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<JoinAttemptId, TicketEntry> _tickets = [];
|
||||||
|
private readonly int _maximumAuthorizedTickets;
|
||||||
|
private readonly IConnectionTicketClock _clock;
|
||||||
|
private readonly byte[] _fingerprintKey = new byte[32];
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public ConnectionTicketValidator(int maximumAuthorizedTickets = 1_024)
|
||||||
|
: this(maximumAuthorizedTickets, new SystemConnectionTicketClock())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ConnectionTicketValidator(
|
||||||
|
int maximumAuthorizedTickets,
|
||||||
|
IConnectionTicketClock clock)
|
||||||
|
{
|
||||||
|
if (maximumAuthorizedTickets is < 1 or > 10_000)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maximumAuthorizedTickets));
|
||||||
|
}
|
||||||
|
|
||||||
|
_maximumAuthorizedTickets = maximumAuthorizedTickets;
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
RandomNumberGenerator.Fill(_fingerprintKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAuthorize(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string connectionTicket,
|
||||||
|
DateTimeOffset expiresAt)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(connectionTicket)
|
||||||
|
|| expiresAt <= now)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveExpired(now);
|
||||||
|
byte[] fingerprint = Fingerprint(connectionTicket);
|
||||||
|
if (_tickets.TryGetValue(attemptId, out TicketEntry? current))
|
||||||
|
{
|
||||||
|
bool idempotent = current.State == TicketState.Active
|
||||||
|
&& current.ExpiresAt == expiresAt
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(current.Fingerprint, fingerprint);
|
||||||
|
CryptographicOperations.ZeroMemory(fingerprint);
|
||||||
|
return idempotent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tickets.Count >= _maximumAuthorizedTickets)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(fingerprint);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickets.Add(attemptId, new(fingerprint, expiresAt));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConnectionTicketConsumptionResult Consume(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
|
||||||
|
{
|
||||||
|
RemoveExpired(now);
|
||||||
|
return ConnectionTicketConsumptionResult.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.ExpiresAt <= now)
|
||||||
|
{
|
||||||
|
Remove(attemptId, entry);
|
||||||
|
return ConnectionTicketConsumptionResult.Expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State == TicketState.Revoked)
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.Revoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State == TicketState.Consumed)
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.AlreadyConsumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] supplied = Fingerprint(connectionTicket);
|
||||||
|
bool matches = CryptographicOperations.FixedTimeEquals(entry.Fingerprint, supplied);
|
||||||
|
CryptographicOperations.ZeroMemory(supplied);
|
||||||
|
if (!matches)
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.State = TicketState.Consumed;
|
||||||
|
return ConnectionTicketConsumptionResult.Accepted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Revoke(JoinAttemptId attemptId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
RemoveExpired(_clock.UtcNow);
|
||||||
|
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.State = TicketState.Revoked;
|
||||||
|
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (TicketEntry entry in _tickets.Values)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickets.Clear();
|
||||||
|
CryptographicOperations.ZeroMemory(_fingerprintKey);
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[ConnectionTicketValidator: tickets and key redacted]";
|
||||||
|
|
||||||
|
private byte[] Fingerprint(string ticket)
|
||||||
|
{
|
||||||
|
byte[] encoded = Encoding.ASCII.GetBytes(ticket);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HMACSHA256 hmac = new(_fingerprintKey);
|
||||||
|
return hmac.ComputeHash(encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveExpired(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
foreach (KeyValuePair<JoinAttemptId, TicketEntry> item in _tickets
|
||||||
|
.Where(item => item.Value.ExpiresAt <= now)
|
||||||
|
.ToArray())
|
||||||
|
{
|
||||||
|
Remove(item.Key, item.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Remove(JoinAttemptId attemptId, TicketEntry entry)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||||
|
_tickets.Remove(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(ConnectionTicketValidator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TicketEntry(byte[] fingerprint, DateTimeOffset expiresAt)
|
||||||
|
{
|
||||||
|
public byte[] Fingerprint { get; } = fingerprint;
|
||||||
|
public DateTimeOffset ExpiresAt { get; } = expiresAt;
|
||||||
|
public TicketState State { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TicketState
|
||||||
|
{
|
||||||
|
Active = 0,
|
||||||
|
Consumed = 1,
|
||||||
|
Revoked = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IConnectionTicketClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemConnectionTicketClock : IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@
|
|||||||
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
|
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
|
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
|
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
||||||
<PackageReference Include="LiteNetLib" />
|
<PackageReference Include="LiteNetLib" />
|
||||||
|
<None Update="README.md" Pack="true" PackagePath="\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousJoinClient : IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
private const string LeaseTokenHeader = "X-Rendezvous-Lease-Token";
|
||||||
|
private const string ClientPunchCapabilityHeader = "X-Rendezvous-Client-Punch-Capability";
|
||||||
|
|
||||||
|
private readonly RendezvousHttpTransport _transport;
|
||||||
|
|
||||||
|
public RendezvousJoinClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options = null,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_transport = new(httpClient, options, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
CreateJoinAttemptRequest body = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
ListingId = request.ListingId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
};
|
||||||
|
return _transport.SendSafeAsync<CreateJoinAttemptResponse>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (dedicatedFallback is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RendezvousClientResult<CreateJoinAttemptResponse> result = await CreateAsync(
|
||||||
|
request,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
elapsed.Stop();
|
||||||
|
return result.IsSuccess && result.Value is not null
|
||||||
|
? RendezvousConnectionStartResult.ReadyForTraversal(result.Value)
|
||||||
|
: RendezvousConnectionStartResult.Completed(
|
||||||
|
RendezvousConnectionOutcome.FromServiceError(
|
||||||
|
result.Error,
|
||||||
|
elapsed.Elapsed,
|
||||||
|
dedicatedFallback));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
elapsed.Stop();
|
||||||
|
return RendezvousConnectionStartResult.Completed(
|
||||||
|
RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.Caller,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Authorization,
|
||||||
|
elapsed.Elapsed,
|
||||||
|
dedicatedFallback));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (attempt is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(attempt));
|
||||||
|
}
|
||||||
|
return _transport.SendSafeAsync<bool>(
|
||||||
|
() => HeaderRequest(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/join-attempts/{attempt.AttemptId}",
|
||||||
|
ClientPunchCapabilityHeader,
|
||||||
|
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
if (pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
string query = $"v1/sessions/{session.ListingId}/join-attempts"
|
||||||
|
+ $"?contractVersion={ContractLimits.ContractVersion}"
|
||||||
|
+ $"&pageSize={pageSize}"
|
||||||
|
+ (cursor is null ? string.Empty : $"&cursor={Uri.EscapeDataString(cursor)}");
|
||||||
|
return _transport.SendSafeAsync<BrowseHostJoinAttemptsResponse>(
|
||||||
|
() => HeaderRequest(
|
||||||
|
HttpMethod.Get,
|
||||||
|
query,
|
||||||
|
LeaseTokenHeader,
|
||||||
|
RequireHeaderValue(session.LeaseToken, nameof(session))),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
if (maximumPages is < 1 or > 1_000)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maximumPages));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<HostJoinAttempt> attempts = [];
|
||||||
|
string? cursor = null;
|
||||||
|
for (int page = 0; page < maximumPages; page++)
|
||||||
|
{
|
||||||
|
RendezvousClientResult<BrowseHostJoinAttemptsResponse> result =
|
||||||
|
await BrowseForHostAsync(
|
||||||
|
session,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
cursor,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.AddRange(result.Value.Items);
|
||||||
|
cursor = result.Value.NextCursor;
|
||||||
|
if (string.IsNullOrEmpty(cursor))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Success<IReadOnlyList<HostJoinAttempt>>(
|
||||||
|
attempts.AsReadOnly());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
$"Host invitation polling exceeded the configured {maximumPages}-page limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousConnectionOutcome outcome,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (attempt is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(attempt));
|
||||||
|
}
|
||||||
|
if (outcome is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(outcome));
|
||||||
|
}
|
||||||
|
if (!ContractValidation.IsReportableConnectionOutcome(outcome.Kind))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"This outcome cannot be reported for an issued join attempt.",
|
||||||
|
nameof(outcome));
|
||||||
|
}
|
||||||
|
|
||||||
|
ReportConnectionOutcomeRequest body = new()
|
||||||
|
{
|
||||||
|
Outcome = outcome.Kind,
|
||||||
|
ElapsedBucket = RendezvousConnectionOutcome.BucketElapsed(outcome.Elapsed),
|
||||||
|
};
|
||||||
|
return _transport.SendSafeAsync<ReportConnectionOutcomeResponse>(
|
||||||
|
() => HeaderJsonRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"v1/join-attempts/{attempt.AttemptId}/outcome",
|
||||||
|
ClientPunchCapabilityHeader,
|
||||||
|
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt)),
|
||||||
|
body),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage HeaderRequest(
|
||||||
|
HttpMethod method,
|
||||||
|
string uri,
|
||||||
|
string header,
|
||||||
|
string value)
|
||||||
|
{
|
||||||
|
HttpRequestMessage request = new(method, uri);
|
||||||
|
request.Headers.TryAddWithoutValidation(header, value);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage HeaderJsonRequest<T>(
|
||||||
|
HttpMethod method,
|
||||||
|
string uri,
|
||||||
|
string header,
|
||||||
|
string value,
|
||||||
|
T body)
|
||||||
|
{
|
||||||
|
HttpRequestMessage request = RendezvousHttpTransport.JsonRequest(method, uri, body);
|
||||||
|
request.Headers.TryAddWithoutValidation(header, value);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RequireHeaderValue(string value, string parameterName) =>
|
||||||
|
!string.IsNullOrWhiteSpace(value)
|
||||||
|
? value
|
||||||
|
: throw new ArgumentException("The required capability is missing.", parameterName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# FinalFactory.Rendezvous.Client
|
||||||
|
|
||||||
|
Godot-independent .NET publisher, browser, join, and LiteNetLib traversal SDK for Rendezvous v1.
|
||||||
|
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
using HttpClient http = new()
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://rendezvous.example/"),
|
||||||
|
};
|
||||||
|
|
||||||
|
string publisherCredential = Environment.GetEnvironmentVariable(
|
||||||
|
"RENDEZVOUS_PUBLISHER_CREDENTIAL")
|
||||||
|
?? throw new InvalidOperationException("Publisher credential is not configured.");
|
||||||
|
CancellationToken cancellationToken = default;
|
||||||
|
RendezvousPublisherClient publisher = new(http);
|
||||||
|
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||||
|
new RegisterSessionRequest
|
||||||
|
{
|
||||||
|
IdempotencyKey = Guid.NewGuid().ToString("N"),
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "My server",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||||
|
DedicatedFallback = new()
|
||||||
|
{
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
Address = "203.0.113.40",
|
||||||
|
Port = 7777,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
publisherCredential,
|
||||||
|
cancellationToken);
|
||||||
|
if (!registered.IsSuccess || registered.Value is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Registration failed: {registered.Error} ({registered.Message})");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Load `publisherCredential` from the game's deployment secret boundary; never
|
||||||
|
embed it in a client build or source control. A successful registration returns a
|
||||||
|
`PublishedSession` containing the lease and host-presence capabilities.
|
||||||
|
Send a periodic presence request from the host's gameplay `NetManager` using the
|
||||||
|
server-controlled refresh interval and the fixed-size native token:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
string presenceToken = NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.HostPresence,
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
session.HostPresenceCapability);
|
||||||
|
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
For direct connections, let the SDK drive those tokens from the same caller-owned
|
||||||
|
LiteNetLib socket that carries gameplay. Ask the routing listener to create the
|
||||||
|
bound manager, then configure and start that caller-owned manager yourself. The
|
||||||
|
factory does not open a socket, and synchronized events must remain enabled:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager gameplayNetManager = networkEvents.CreateManager();
|
||||||
|
if (!gameplayNetManager.Start(0))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The gameplay UDP socket could not start.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The host polls join invitations asynchronously; that method only queues a
|
||||||
|
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
|
||||||
|
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
|
||||||
|
frame on the thread that owns the manager:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousJoinClient joins = new(http);
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
gameplayNetManager,
|
||||||
|
networkEvents,
|
||||||
|
mediatorEndPoint,
|
||||||
|
session,
|
||||||
|
joins);
|
||||||
|
|
||||||
|
// Run periodically from the game's normal async scheduling path.
|
||||||
|
await host.RefreshJoinAttemptsAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Godot _Process, Update, or the equivalent main-thread frame callback.
|
||||||
|
host.Poll();
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not also call `gameplayNetManager.PollEvents()` or
|
||||||
|
`gameplayNetManager.NatPunchModule.PollEvents()` when a coordinator owns polling.
|
||||||
|
The host coordinator refreshes host presence, punches for queued invitations,
|
||||||
|
validates the introduction ticket, and accepts the direct request. Subscribe to
|
||||||
|
`AttemptCompleted`; a `Connected` result is raised only after LiteNetLib reports
|
||||||
|
the accepted peer as connected. Register ordinary gameplay callbacks on
|
||||||
|
`networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct
|
||||||
|
requests for ticket validation and forwards every other callback normally.
|
||||||
|
|
||||||
|
The joining game first requests an attempt through the typed start API. It returns
|
||||||
|
exactly one issued attempt or one terminal service outcome, so service authority
|
||||||
|
is not confused with a later locally observed traversal failure:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync(
|
||||||
|
createJoinRequest,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (start.Outcome is { } serviceOutcome)
|
||||||
|
{
|
||||||
|
ShowConnectionFailure(serviceOutcome.Kind, serviceOutcome.Category);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateJoinAttemptResponse attempt = start.Attempt
|
||||||
|
?? throw new InvalidOperationException("The typed start result was invalid.");
|
||||||
|
using RendezvousClientCoordinator client = new(
|
||||||
|
gameplayNetManager,
|
||||||
|
networkEvents,
|
||||||
|
mediatorEndPoint,
|
||||||
|
attempt);
|
||||||
|
|
||||||
|
// Godot _Process, Update, or the equivalent main-thread frame callback.
|
||||||
|
client.Poll();
|
||||||
|
```
|
||||||
|
|
||||||
|
NAT introduction changes the client state to `Connecting`; it is not success.
|
||||||
|
Only a `Connected` outcome supplies `Peer`. Completion exposes a stable kind,
|
||||||
|
source, category, phase, and elapsed duration. The default HTTP silence, punch,
|
||||||
|
and direct-connect budgets are five, ten, and five seconds respectively; configure
|
||||||
|
them through `RendezvousClientOptions` and `RendezvousCoordinatorOptions` when a
|
||||||
|
game has measured reasons to do so. The signed attempt expiry is always the
|
||||||
|
absolute upper bound.
|
||||||
|
|
||||||
|
Call `Cancel()` and then `Poll()` for local cancellation, or
|
||||||
|
`CancelAsync(joins, cancellationToken)` to also revoke the service attempt.
|
||||||
|
Terminal client paths complete exactly once and release all event subscriptions,
|
||||||
|
so late packets and callbacks are inert. Disposing a coordinator never stops or
|
||||||
|
disposes the caller-owned manager and does not touch an in-flight peer; call
|
||||||
|
`Cancel()` followed by `Poll()` first when that peer must also be disconnected.
|
||||||
|
|
||||||
|
After terminal completion, reporting is explicit and safe to retry. It sends only
|
||||||
|
the authenticated outcome enum and a coarse elapsed bucket—never the endpoint,
|
||||||
|
exact duration, diagnostic text, metadata, or player identity:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousClientResult<ReportConnectionOutcomeResponse> report =
|
||||||
|
await client.ReportOutcomeAsync(joins, cancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
An optional `DedicatedFallback` is copied from the authoritative listing into the
|
||||||
|
issued attempt and terminal outcome. A local deployment may replace it with
|
||||||
|
`RendezvousCoordinatorOptions.DedicatedFallbackOverride`. The SDK only returns
|
||||||
|
the endpoint; it never connects automatically. The game must explicitly decide
|
||||||
|
whether to use it and then connect and authenticate through its own gameplay
|
||||||
|
transport. If the outcome has no fallback, v1 offers no relay.
|
||||||
|
|
||||||
|
Lease renewal is explicit and caller-controlled:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
PublishedSession session = registered.Value;
|
||||||
|
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||||
|
session,
|
||||||
|
publisherCredential);
|
||||||
|
LeaseMaintenanceResult stopped = await maintainer.RunAsync(cancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
Creating the maintainer does not start background work. Await its run and dispose
|
||||||
|
it when hosting stops. Use `IRendezvousPublisherClient` and
|
||||||
|
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
|
||||||
|
the requests and responses it creates but never disposes the supplied `HttpClient`.
|
||||||
|
|
||||||
|
The host-side `ConnectionTicketValidator` is a bounded, thread-safe one-time gate.
|
||||||
|
Authorize only tickets delivered by the authenticated Rendezvous introduction,
|
||||||
|
then consume the exact ticket presented by the direct LiteNetLib connection:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ConnectionTicketValidator tickets = new();
|
||||||
|
tickets.TryAuthorize(attemptId, expectedTicket, expiresAt);
|
||||||
|
ConnectionTicketConsumptionResult admission = tickets.Consume(
|
||||||
|
attemptId,
|
||||||
|
presentedTicket);
|
||||||
|
```
|
||||||
|
|
||||||
|
An `Accepted` ticket authorizes only this connection attempt. The game must still
|
||||||
|
apply its own player identity, capacity, ban, and gameplay admission rules. Revoke
|
||||||
|
the attempt on cancellation and dispose the validator during host shutdown so its
|
||||||
|
keyed ticket digests are zeroed.
|
||||||
|
|
||||||
|
See the repository's ADR 0007 for HTTP ownership/retry semantics, ADR 0008 for
|
||||||
|
join-capability and connection-ticket security semantics, and ADR 0010 for typed
|
||||||
|
outcomes, deadlines, reporting, and caller-owned fallback.
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientResult<T>
|
||||||
|
{
|
||||||
|
internal RendezvousClientResult(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
T? value,
|
||||||
|
string message,
|
||||||
|
int? retryAfterSeconds)
|
||||||
|
{
|
||||||
|
Error = error;
|
||||||
|
Value = value;
|
||||||
|
Message = message;
|
||||||
|
RetryAfterSeconds = retryAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsSuccess => Error == RendezvousErrorCode.None;
|
||||||
|
public RendezvousErrorCode Error { get; }
|
||||||
|
public T? Value { get; }
|
||||||
|
public string Message { get; }
|
||||||
|
public int? RetryAfterSeconds { get; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RendezvousClientResult
|
||||||
|
{
|
||||||
|
public static RendezvousClientResult<T> Success<T>(T value) =>
|
||||||
|
value is null
|
||||||
|
? throw new ArgumentNullException(nameof(value))
|
||||||
|
: new(RendezvousErrorCode.None, value, string.Empty, null);
|
||||||
|
|
||||||
|
public static RendezvousClientResult<T> Failure<T>(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
string message,
|
||||||
|
int? retryAfterSeconds = null) =>
|
||||||
|
error == RendezvousErrorCode.None
|
||||||
|
? throw new ArgumentException("A failure requires a non-success error.", nameof(error))
|
||||||
|
: new(error, default, message ?? string.Empty, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PublishedSession
|
||||||
|
{
|
||||||
|
private readonly object _timingGate = new();
|
||||||
|
private DateTimeOffset _expiresAt;
|
||||||
|
private int _leaseRenewAfterSeconds;
|
||||||
|
|
||||||
|
internal PublishedSession(RegisterSessionResponse response)
|
||||||
|
{
|
||||||
|
ListingId = response.ListingId;
|
||||||
|
LeaseId = response.LeaseId;
|
||||||
|
LeaseToken = response.LeaseToken;
|
||||||
|
HostPresenceHandle = response.HostPresenceHandle;
|
||||||
|
HostPresenceCapability = response.HostPresenceCapability;
|
||||||
|
_expiresAt = response.ExpiresAt;
|
||||||
|
_leaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||||
|
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionListingId ListingId { get; }
|
||||||
|
public LeaseId LeaseId { get; }
|
||||||
|
public string LeaseToken { get; }
|
||||||
|
public MediationHandle HostPresenceHandle { get; }
|
||||||
|
public string HostPresenceCapability { get; }
|
||||||
|
public DateTimeOffset ExpiresAt
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
return _expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal set
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
_expiresAt = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int LeaseRenewAfterSeconds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
return _leaseRenewAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal set
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
_leaseRenewAfterSeconds = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public int HostPresenceRefreshAfterSeconds { get; }
|
||||||
|
|
||||||
|
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousPublisherClient
|
||||||
|
{
|
||||||
|
Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||||
|
RegisterSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
UpdateSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousSessionBrowserClient
|
||||||
|
{
|
||||||
|
Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||||
|
SessionListingId listingId,
|
||||||
|
GameId gameId,
|
||||||
|
EnvironmentId environmentId,
|
||||||
|
uint protocolVersion,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousConnectionOutcome outcome,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousDelay
|
||||||
|
{
|
||||||
|
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousClientOptions
|
||||||
|
{
|
||||||
|
public int MaximumSafeRetries { get; set; } = 2;
|
||||||
|
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||||
|
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||||
|
public double JitterRatio { get; set; } = 0.2;
|
||||||
|
|
||||||
|
internal void Validate()
|
||||||
|
{
|
||||||
|
if (MaximumSafeRetries is < 0 or > 5
|
||||||
|
|| RequestTimeout <= TimeSpan.Zero
|
||||||
|
|| RequestTimeout > TimeSpan.FromSeconds(30)
|
||||||
|
|| InitialRetryDelay < TimeSpan.Zero
|
||||||
|
|| MaximumRetryDelay < InitialRetryDelay
|
||||||
|
|| MaximumRetryDelay > TimeSpan.FromSeconds(30)
|
||||||
|
|| JitterRatio is < 0 or > 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(RendezvousClientOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemRendezvousDelay : IRendezvousDelay
|
||||||
|
{
|
||||||
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
|
||||||
|
Task.Delay(delay, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RendezvousEndpoint
|
||||||
|
{
|
||||||
|
internal static NetworkEndpoint? Copy(NetworkEndpoint? endpoint) => endpoint is null
|
||||||
|
? null
|
||||||
|
: new NetworkEndpoint
|
||||||
|
{
|
||||||
|
AddressFamily = endpoint.AddressFamily,
|
||||||
|
Address = endpoint.Address,
|
||||||
|
Port = endpoint.Port,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
internal sealed class RendezvousHttpTransport
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly RendezvousClientOptions _options;
|
||||||
|
private readonly IRendezvousDelay _delay;
|
||||||
|
|
||||||
|
internal RendezvousHttpTransport(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options,
|
||||||
|
IRendezvousDelay? delay)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||||
|
RendezvousClientOptions suppliedOptions = options ?? new RendezvousClientOptions();
|
||||||
|
suppliedOptions.Validate();
|
||||||
|
_options = new RendezvousClientOptions
|
||||||
|
{
|
||||||
|
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
|
||||||
|
RequestTimeout = suppliedOptions.RequestTimeout,
|
||||||
|
InitialRetryDelay = suppliedOptions.InitialRetryDelay,
|
||||||
|
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
|
||||||
|
JitterRatio = suppliedOptions.JitterRatio,
|
||||||
|
};
|
||||||
|
_delay = delay ?? new SystemRendezvousDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<RendezvousClientResult<T>> SendSafeAsync<T>(
|
||||||
|
Func<HttpRequestMessage> requestFactory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (int attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
using CancellationTokenSource requestTimeout =
|
||||||
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
requestTimeout.CancelAfter(_options.RequestTimeout);
|
||||||
|
CancellationToken requestCancellation = requestTimeout.Token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpRequestMessage request = requestFactory();
|
||||||
|
using HttpResponseMessage response = await _httpClient
|
||||||
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestCancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
if (typeof(T) == typeof(bool) && response.StatusCode == HttpStatusCode.NoContent)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Success((T)(object)true);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = await ReadBoundedAsync(response.Content, requestCancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<T>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an oversized success response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
T? value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
value = JsonSerializer.Deserialize<T>(payload, ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value is null
|
||||||
|
? RendezvousClientResult.Failure<T>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an invalid success response.")
|
||||||
|
: RendezvousClientResult.Success(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
|
||||||
|
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||||
|
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
|
||||||
|
{
|
||||||
|
await _delay.DelayAsync(
|
||||||
|
GetRetryDelay(attempt, retryAfter),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Failure<T>(error.Code, error.Message, retryAfter);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
IsTransientTransportFailure(exception, cancellationToken)
|
||||||
|
&& attempt < _options.MaximumSafeRetries)
|
||||||
|
{
|
||||||
|
await _delay.DelayAsync(GetRetryDelay(attempt, null), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<T>(
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
"The Rendezvous service did not return a valid response.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static HttpRequestMessage JsonRequest<T>(
|
||||||
|
HttpMethod method,
|
||||||
|
string uri,
|
||||||
|
T body,
|
||||||
|
string? publisherCredential = null)
|
||||||
|
{
|
||||||
|
HttpRequestMessage request = new(method, uri)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(body, ContractJson.Options),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"),
|
||||||
|
};
|
||||||
|
if (publisherCredential is not null)
|
||||||
|
{
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||||
|
"Bearer",
|
||||||
|
RequireCredential(publisherCredential));
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string RequireCredential(string credential) =>
|
||||||
|
!string.IsNullOrWhiteSpace(credential)
|
||||||
|
? credential
|
||||||
|
: throw new ArgumentException("A publisher credential is required.", nameof(credential));
|
||||||
|
|
||||||
|
private static async Task<ApiError> ReadErrorAsync(
|
||||||
|
HttpResponseMessage response,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ApiError? error = JsonSerializer.Deserialize<ApiError>(payload, ContractJson.Options);
|
||||||
|
return error is not null && error.Code != RendezvousErrorCode.None
|
||||||
|
? error
|
||||||
|
: FallbackError(response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is JsonException or InvalidDataException)
|
||||||
|
{
|
||||||
|
return FallbackError(response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> ReadBoundedAsync(
|
||||||
|
HttpContent content,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using Stream source = await content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||||
|
using MemoryStream destination = new();
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int read = await source.ReadAsync(buffer.AsMemory(), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
return destination.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.Length + read > ContractLimits.BrowserResponseMaxBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("The service response exceeded the SDK limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSpan GetRetryDelay(int attempt, int? retryAfterSeconds)
|
||||||
|
{
|
||||||
|
TimeSpan basis = retryAfterSeconds.HasValue
|
||||||
|
? TimeSpan.FromSeconds(Math.Max(0, retryAfterSeconds.Value))
|
||||||
|
: TimeSpan.FromMilliseconds(
|
||||||
|
_options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt));
|
||||||
|
double bounded = Math.Min(basis.TotalMilliseconds, _options.MaximumRetryDelay.TotalMilliseconds);
|
||||||
|
if (_options.JitterRatio == 0 || bounded == 0)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromMilliseconds(bounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] random = new byte[1];
|
||||||
|
RandomNumberGenerator.Fill(random);
|
||||||
|
double unit = random[0] / 255d;
|
||||||
|
double multiplier = 1 - _options.JitterRatio + (2 * _options.JitterRatio * unit);
|
||||||
|
return TimeSpan.FromMilliseconds(Math.Min(
|
||||||
|
bounded * multiplier,
|
||||||
|
_options.MaximumRetryDelay.TotalMilliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTransient(RendezvousErrorCode code) => code is
|
||||||
|
RendezvousErrorCode.RateLimited
|
||||||
|
or RendezvousErrorCode.CapacityExceeded
|
||||||
|
or RendezvousErrorCode.ServiceUnavailable;
|
||||||
|
|
||||||
|
private static bool IsTransientTransportFailure(
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken callerCancellation) =>
|
||||||
|
exception is HttpRequestException
|
||||||
|
or IOException
|
||||||
|
|| exception is OperationCanceledException && !callerCancellation.IsCancellationRequested;
|
||||||
|
|
||||||
|
private static int? GetRetryAfterSeconds(RetryConditionHeaderValue? retryAfter) =>
|
||||||
|
retryAfter?.Delta is TimeSpan delta
|
||||||
|
? Math.Max(0, (int)Math.Ceiling(delta.TotalSeconds))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static ApiError FallbackError(HttpStatusCode statusCode) => new()
|
||||||
|
{
|
||||||
|
Code = statusCode switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.BadRequest => RendezvousErrorCode.InvalidRequest,
|
||||||
|
HttpStatusCode.Unauthorized => RendezvousErrorCode.AuthenticationRequired,
|
||||||
|
HttpStatusCode.Forbidden => RendezvousErrorCode.Forbidden,
|
||||||
|
HttpStatusCode.NotFound => RendezvousErrorCode.NotFound,
|
||||||
|
HttpStatusCode.Conflict => RendezvousErrorCode.Conflict,
|
||||||
|
HttpStatusCode.Gone => RendezvousErrorCode.Expired,
|
||||||
|
HttpStatusCode.TooManyRequests => RendezvousErrorCode.RateLimited,
|
||||||
|
HttpStatusCode.RequestTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.BadGateway => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.GatewayTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
_ => RendezvousErrorCode.InternalError,
|
||||||
|
},
|
||||||
|
Message = "The service returned an error without a valid Rendezvous envelope.",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
||||||
|
{
|
||||||
|
private readonly RendezvousHttpTransport _transport;
|
||||||
|
private readonly IRendezvousDelay _delay;
|
||||||
|
|
||||||
|
public RendezvousPublisherClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options = null,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_delay = delay ?? new SystemRendezvousDelay();
|
||||||
|
_transport = new(httpClient, options, _delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||||
|
RegisterSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterSessionRequest body = CopyRegistration(request);
|
||||||
|
RendezvousClientResult<RegisterSessionResponse> result = await _transport.SendSafeAsync<RegisterSessionResponse>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/sessions", body, publisherCredential),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return result.IsSuccess && result.Value is not null
|
||||||
|
? RendezvousClientResult.Success(new PublishedSession(result.Value))
|
||||||
|
: RendezvousClientResult.Failure<PublishedSession>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousClientResult<RenewLeaseResponse> result = await _transport.SendSafeAsync<RenewLeaseResponse>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"v1/sessions/{session.ListingId}/renew",
|
||||||
|
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
|
||||||
|
publisherCredential),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (result.IsSuccess && result.Value is not null)
|
||||||
|
{
|
||||||
|
session.ExpiresAt = result.Value.ExpiresAt;
|
||||||
|
session.LeaseRenewAfterSeconds = result.Value.RenewAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
UpdateSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateSessionRequest body = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
LeaseToken = session.LeaseToken,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Capacity = CopyCapacity(request.Capacity),
|
||||||
|
Metadata = CopyMetadata(request.Metadata),
|
||||||
|
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
|
||||||
|
};
|
||||||
|
return _transport.SendSafeAsync<bool>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(
|
||||||
|
HttpMethod.Put,
|
||||||
|
$"v1/sessions/{session.ListingId}",
|
||||||
|
body,
|
||||||
|
publisherCredential),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
return _transport.SendSafeAsync<bool>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/sessions/{session.ListingId}",
|
||||||
|
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
|
||||||
|
publisherCredential),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionLeaseMaintainer CreateLeaseMaintainer(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential) => new(
|
||||||
|
this,
|
||||||
|
session ?? throw new ArgumentNullException(nameof(session)),
|
||||||
|
RendezvousHttpTransport.RequireCredential(publisherCredential),
|
||||||
|
_delay);
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CopyRegistration(RegisterSessionRequest request) => new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Visibility = request.Visibility,
|
||||||
|
Capacity = CopyCapacity(request.Capacity),
|
||||||
|
Metadata = CopyMetadata(request.Metadata),
|
||||||
|
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new()
|
||||||
|
{
|
||||||
|
CurrentPlayers = capacity.CurrentPlayers,
|
||||||
|
MaximumPlayers = capacity.MaximumPlayers,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) =>
|
||||||
|
new(metadata, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserClient
|
||||||
|
{
|
||||||
|
private readonly RendezvousHttpTransport _transport;
|
||||||
|
|
||||||
|
public RendezvousSessionBrowserClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options = null,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_transport = new(httpClient, options, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
string query = $"v1/sessions?contractVersion={request.ContractVersion}"
|
||||||
|
+ $"&gameId={Escape(request.GameId.Value)}"
|
||||||
|
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
|
||||||
|
+ $"&protocolVersion={request.ProtocolVersion}"
|
||||||
|
+ $"&pageSize={request.PageSize}"
|
||||||
|
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
|
||||||
|
+ (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty)
|
||||||
|
+ (request.Cursor is not null ? $"&cursor={Escape(request.Cursor)}" : string.Empty);
|
||||||
|
return _transport.SendSafeAsync<BrowseSessionsResponse>(
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maximumPages is < 1 or > 1000)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maximumPages));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SessionListing> items = [];
|
||||||
|
string? cursor = request.Cursor;
|
||||||
|
for (int page = 0; page < maximumPages; page++)
|
||||||
|
{
|
||||||
|
BrowseSessionsRequest pageRequest = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
PageSize = request.PageSize,
|
||||||
|
ExcludeFull = request.ExcludeFull,
|
||||||
|
Cursor = cursor,
|
||||||
|
};
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> result = await BrowseAsync(
|
||||||
|
pageRequest,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.AddRange(result.Value.Items);
|
||||||
|
cursor = result.Value.NextCursor;
|
||||||
|
if (string.IsNullOrEmpty(cursor))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Success<IReadOnlyList<SessionListing>>(items.AsReadOnly());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
$"Browsing exceeded the configured {maximumPages}-page limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||||
|
SessionListingId listingId,
|
||||||
|
GameId gameId,
|
||||||
|
EnvironmentId environmentId,
|
||||||
|
uint protocolVersion,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string query = $"v1/sessions/{listingId}?contractVersion={ContractLimits.ContractVersion}"
|
||||||
|
+ $"&gameId={Escape(gameId.Value)}"
|
||||||
|
+ $"&environmentId={Escape(environmentId.Value)}"
|
||||||
|
+ $"&protocolVersion={protocolVersion}";
|
||||||
|
return _transport.SendSafeAsync<GetSessionResponse>(
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum LeaseMaintenanceStopReason
|
||||||
|
{
|
||||||
|
Cancelled = 1,
|
||||||
|
Disposed = 2,
|
||||||
|
LeaseLost = 3,
|
||||||
|
Failed = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LeaseMaintenanceResult
|
||||||
|
{
|
||||||
|
internal LeaseMaintenanceResult(LeaseMaintenanceStopReason reason, RendezvousErrorCode error)
|
||||||
|
{
|
||||||
|
Reason = reason;
|
||||||
|
Error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeaseMaintenanceStopReason Reason { get; }
|
||||||
|
public RendezvousErrorCode Error { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SessionLeaseMaintainer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly IRendezvousPublisherClient _publisher;
|
||||||
|
private readonly PublishedSession _session;
|
||||||
|
private readonly string _publisherCredential;
|
||||||
|
private readonly IRendezvousDelay _delay;
|
||||||
|
private readonly CancellationTokenSource _disposeCancellation = new();
|
||||||
|
private Task<LeaseMaintenanceResult>? _activeRun;
|
||||||
|
private Task? _disposeTask;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal SessionLeaseMaintainer(
|
||||||
|
IRendezvousPublisherClient publisher,
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_publisher = publisher;
|
||||||
|
_session = session;
|
||||||
|
_publisherCredential = publisherCredential;
|
||||||
|
_delay = delay ?? new SystemRendezvousDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? LeaseLost;
|
||||||
|
|
||||||
|
public Task<LeaseMaintenanceResult> RunAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(SessionLeaseMaintainer));
|
||||||
|
}
|
||||||
|
if (_activeRun is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Lease maintenance is already running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeRun = RunCoreAsync(cancellationToken);
|
||||||
|
return _activeRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposeTask is not null)
|
||||||
|
{
|
||||||
|
return new(_disposeTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_disposeCancellation.Cancel();
|
||||||
|
_disposeTask = FinishDisposeAsync(_activeRun);
|
||||||
|
return new(_disposeTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinishDisposeAsync(Task<LeaseMaintenanceResult>? active)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (active is not null)
|
||||||
|
{
|
||||||
|
await active.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_disposeCancellation.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LeaseMaintenanceResult> RunCoreAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken,
|
||||||
|
_disposeCancellation.Token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
await _delay.DelayAsync(
|
||||||
|
TimeSpan.FromSeconds(Math.Max(1, _session.LeaseRenewAfterSeconds)),
|
||||||
|
linked.Token).ConfigureAwait(false);
|
||||||
|
RendezvousClientResult<RenewLeaseResponse> renewed = await _publisher.RenewAsync(
|
||||||
|
_session,
|
||||||
|
_publisherCredential,
|
||||||
|
linked.Token).ConfigureAwait(false);
|
||||||
|
if (renewed.IsSuccess)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renewed.Error is RendezvousErrorCode.NotFound
|
||||||
|
or RendezvousErrorCode.Expired
|
||||||
|
or RendezvousErrorCode.Forbidden
|
||||||
|
or RendezvousErrorCode.AuthenticationRequired)
|
||||||
|
{
|
||||||
|
LeaseLost?.Invoke(this, EventArgs.Empty);
|
||||||
|
return new(LeaseMaintenanceStopReason.LeaseLost, renewed.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(LeaseMaintenanceStopReason.Failed, renewed.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (linked.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return new(
|
||||||
|
_disposeCancellation.IsCancellationRequested
|
||||||
|
? LeaseMaintenanceStopReason.Disposed
|
||||||
|
: LeaseMaintenanceStopReason.Cancelled,
|
||||||
|
RendezvousErrorCode.None);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_activeRun = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class DirectConnectionRequest
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId { get; set; }
|
||||||
|
public string ConnectionTicket { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[DirectConnectionRequest {AttemptId}; ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DirectConnectionRequestCodec
|
||||||
|
{
|
||||||
|
public const int EncodedLength = 63;
|
||||||
|
|
||||||
|
private const int MagicLength = 4;
|
||||||
|
private const int AttemptIdLength = 16;
|
||||||
|
private const int TicketLength = ContractLimits.DerivedCredentialCharacters;
|
||||||
|
private static readonly byte[] Magic = [(byte)'R', (byte)'V', (byte)'D', (byte)'1'];
|
||||||
|
|
||||||
|
public static bool IsRendezvousRequest(ReadOnlySpan<byte> encoded) =>
|
||||||
|
encoded.Length >= MagicLength && encoded[..MagicLength].SequenceEqual(Magic);
|
||||||
|
|
||||||
|
public static byte[] Encode(JoinAttemptId attemptId, string connectionTicket)
|
||||||
|
{
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| connectionTicket is null
|
||||||
|
|| connectionTicket.Length != TicketLength
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The direct connection request fields are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] encoded = new byte[EncodedLength];
|
||||||
|
Magic.CopyTo(encoded, 0);
|
||||||
|
if (!attemptId.Value.TryWriteBytes(encoded.AsSpan(MagicLength, AttemptIdLength)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Encoding.ASCII.GetBytes(
|
||||||
|
connectionTicket,
|
||||||
|
0,
|
||||||
|
connectionTicket.Length,
|
||||||
|
encoded,
|
||||||
|
MagicLength + AttemptIdLength);
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
out DirectConnectionRequest? request)
|
||||||
|
{
|
||||||
|
request = null;
|
||||||
|
if (encoded.Length != EncodedLength
|
||||||
|
|| !encoded[..MagicLength].SequenceEqual(Magic))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid attemptId = new(encoded.Slice(MagicLength, AttemptIdLength));
|
||||||
|
if (attemptId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string ticket = Encoding.ASCII.GetString(encoded[(MagicLength + AttemptIdLength)..]);
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(ticket))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
request = new DirectConnectionRequest
|
||||||
|
{
|
||||||
|
AttemptId = new JoinAttemptId(attemptId),
|
||||||
|
ConnectionTicket = ticket,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientCoordinator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly NetManager _manager;
|
||||||
|
private readonly RendezvousNetListener _networkEvents;
|
||||||
|
private readonly EventBasedNatPunchListener _punchEvents;
|
||||||
|
private readonly IPEndPoint _mediator;
|
||||||
|
private readonly CreateJoinAttemptResponse _attempt;
|
||||||
|
private readonly IRendezvousCoordinatorClock _clock;
|
||||||
|
private readonly RendezvousCoordinatorOptions _options;
|
||||||
|
private readonly RendezvousPunchRetrySchedule _retry;
|
||||||
|
private readonly object _completionGate = new();
|
||||||
|
private readonly TimeSpan _startedAt;
|
||||||
|
private readonly TimeSpan _attemptDeadline;
|
||||||
|
private readonly TimeSpan _punchDeadline;
|
||||||
|
private readonly NetworkEndpoint? _dedicatedFallback;
|
||||||
|
private NetPeer? _connectingPeer;
|
||||||
|
private IPEndPoint? _directEndpoint;
|
||||||
|
private TimeSpan? _directDeadline;
|
||||||
|
private RendezvousConnectionOutcome? _outcome;
|
||||||
|
private bool _cancelRequested;
|
||||||
|
private int _polling;
|
||||||
|
private bool _subscriptionsReleased;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
public RendezvousClientCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousCoordinatorOptions? options = null)
|
||||||
|
: this(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
mediator,
|
||||||
|
attempt,
|
||||||
|
options,
|
||||||
|
new SystemRendezvousCoordinatorClock())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousClientCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousCoordinatorOptions? options,
|
||||||
|
IRendezvousCoordinatorClock clock)
|
||||||
|
{
|
||||||
|
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||||
|
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
|
||||||
|
_punchEvents = _networkEvents.PunchEvents;
|
||||||
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
_attempt = attempt ?? throw new ArgumentNullException(nameof(attempt));
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
_options = (options ?? new RendezvousCoordinatorOptions())
|
||||||
|
.CopyAndValidate();
|
||||||
|
_retry = new(_options, _clock);
|
||||||
|
|
||||||
|
RendezvousManagerGuard.Validate(_manager, _networkEvents);
|
||||||
|
DateTimeOffset startedUtc = _clock.UtcNow;
|
||||||
|
if (_mediator.Port is < 1 or > 65_535
|
||||||
|
|| _attempt.AttemptId.Value == Guid.Empty
|
||||||
|
|| _attempt.MediationHandle.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability)
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest)
|
||||||
|
|| _attempt.ExpiresAt <= startedUtc)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The client traversal inputs are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_startedAt = _clock.Elapsed;
|
||||||
|
_attemptDeadline = _startedAt + (_attempt.ExpiresAt - startedUtc);
|
||||||
|
_punchDeadline = Min(_attemptDeadline, _startedAt + _options.PunchTimeout);
|
||||||
|
_dedicatedFallback = RendezvousEndpoint.Copy(
|
||||||
|
_options.DedicatedFallbackOverride ?? _attempt.DedicatedFallback);
|
||||||
|
|
||||||
|
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
|
||||||
|
_networkEvents.RendezvousNetworkError += OnNetworkError;
|
||||||
|
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<RendezvousConnectionCompletedEventArgs>? Completed;
|
||||||
|
|
||||||
|
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
|
||||||
|
public NetPeer? ConnectedPeer { get; private set; }
|
||||||
|
public RendezvousConnectionOutcome? Outcome => Volatile.Read(ref _outcome);
|
||||||
|
public bool IsCompleted => Outcome is not null;
|
||||||
|
|
||||||
|
public void Cancel() => Volatile.Write(ref _cancelRequested, true);
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (joinClient is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(joinClient));
|
||||||
|
}
|
||||||
|
|
||||||
|
ThrowIfDisposed();
|
||||||
|
Cancel();
|
||||||
|
return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (joinClient is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(joinClient));
|
||||||
|
}
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (Outcome is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The connection attempt has not completed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinClient.ReportOutcomeAsync(_attempt, Outcome, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Poll()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Interlocked.Exchange(ref _polling, 1) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _cancelRequested))
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Cancelled,
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.Caller,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
CurrentPhase());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
CompleteManagerStopped();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.PollEvents();
|
||||||
|
_manager.NatPunchModule.PollEvents();
|
||||||
|
if (IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
TimeSpan elapsed = _clock.Elapsed;
|
||||||
|
if (Volatile.Read(ref _cancelRequested))
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Cancelled,
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.Caller,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
CurrentPhase());
|
||||||
|
}
|
||||||
|
else if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
CompleteManagerStopped();
|
||||||
|
}
|
||||||
|
else if (now >= _attempt.ExpiresAt || elapsed >= _attemptDeadline)
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
ConnectionOutcomeKind.AttemptExpired,
|
||||||
|
RendezvousConnectionOutcomeSource.RendezvousService,
|
||||||
|
RendezvousConnectionFailureCategory.Authorization,
|
||||||
|
RendezvousConnectionPhase.Authorization);
|
||||||
|
}
|
||||||
|
else if (State == RendezvousConnectionState.Punching)
|
||||||
|
{
|
||||||
|
if (elapsed >= _punchDeadline
|
||||||
|
|| _retry.IsExhausted && _retry.IsDue(elapsed))
|
||||||
|
{
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.NatTraversal,
|
||||||
|
RendezvousConnectionPhase.NatTraversal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_retry.IsDue(elapsed))
|
||||||
|
{
|
||||||
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
_mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Client,
|
||||||
|
_attempt.MediationHandle,
|
||||||
|
_attempt.ClientPunchCapability));
|
||||||
|
_retry.RecordRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (State == RendezvousConnectionState.Connecting
|
||||||
|
&& _directDeadline is TimeSpan directDeadline
|
||||||
|
&& directDeadline <= elapsed)
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
ConnectionOutcomeKind.DirectConnectTimedOut,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _polling, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsCompleted)
|
||||||
|
{
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Disposed,
|
||||||
|
ConnectionOutcomeKind.Disposed,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
CurrentPhase());
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseSubscriptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[RendezvousClientCoordinator {_attempt.AttemptId}; credentials redacted]";
|
||||||
|
|
||||||
|
private void OnNatIntroductionSuccess(
|
||||||
|
IPEndPoint target,
|
||||||
|
NatAddressType addressType,
|
||||||
|
string encodedIntroduction)
|
||||||
|
{
|
||||||
|
_ = addressType;
|
||||||
|
if (State != RendezvousConnectionState.Punching
|
||||||
|
|| !NatIntroductionTokenCodec.TryDecode(
|
||||||
|
encodedIntroduction,
|
||||||
|
out NatIntroductionToken? introduction)
|
||||||
|
|| introduction is null
|
||||||
|
|| introduction.AttemptId != _attempt.AttemptId
|
||||||
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
_attempt.ConnectionTicketDigest))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] connectionData = DirectConnectionRequestCodec.Encode(
|
||||||
|
introduction.AttemptId,
|
||||||
|
introduction.ConnectionTicket);
|
||||||
|
_directEndpoint = target;
|
||||||
|
_connectingPeer = _manager.Connect(target, connectionData);
|
||||||
|
if (_connectingPeer is null
|
||||||
|
|| _connectingPeer.ConnectionState != ConnectionState.Outgoing)
|
||||||
|
{
|
||||||
|
_connectingPeer = null;
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
ConnectionOutcomeKind.TransportError,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State = RendezvousConnectionState.Connecting;
|
||||||
|
_directDeadline = Min(
|
||||||
|
_attemptDeadline,
|
||||||
|
_clock.Elapsed + _options.DirectConnectTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerConnected(NetPeer peer)
|
||||||
|
{
|
||||||
|
if (State != RendezvousConnectionState.Connecting
|
||||||
|
|| !ReferenceEquals(peer, _connectingPeer))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Connected,
|
||||||
|
ConnectionOutcomeKind.Connected,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.None,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||||
|
{
|
||||||
|
if (State == RendezvousConnectionState.Connecting
|
||||||
|
&& ReferenceEquals(peer, _connectingPeer))
|
||||||
|
{
|
||||||
|
ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
|
||||||
|
? ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
: disconnectInfo.Reason == DisconnectReason.ConnectionFailed
|
||||||
|
? ConnectionOutcomeKind.TransportError
|
||||||
|
: ConnectionOutcomeKind.HostRejected;
|
||||||
|
Complete(
|
||||||
|
kind == ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
? RendezvousConnectionState.TimedOut
|
||||||
|
: RendezvousConnectionState.Rejected,
|
||||||
|
kind,
|
||||||
|
kind == ConnectionOutcomeKind.HostRejected
|
||||||
|
? RendezvousConnectionOutcomeSource.RemoteHost
|
||||||
|
: RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
|
||||||
|
{
|
||||||
|
_ = socketError;
|
||||||
|
if (State == RendezvousConnectionState.Punching && endpoint.Equals(_mediator))
|
||||||
|
{
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
ConnectionOutcomeKind.MediatorUnavailable,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.Mediation,
|
||||||
|
RendezvousConnectionPhase.Mediation);
|
||||||
|
}
|
||||||
|
else if (State == RendezvousConnectionState.Connecting
|
||||||
|
&& endpoint.Equals(_directEndpoint))
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
ConnectionOutcomeKind.TransportError,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisconnectPendingPeer()
|
||||||
|
{
|
||||||
|
if (_connectingPeer is not null && State == RendezvousConnectionState.Connecting)
|
||||||
|
{
|
||||||
|
_connectingPeer.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Complete(
|
||||||
|
RendezvousConnectionState terminalState,
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
NetPeer? peer = null)
|
||||||
|
{
|
||||||
|
RendezvousConnectionCompletedEventArgs completion;
|
||||||
|
lock (_completionGate)
|
||||||
|
{
|
||||||
|
if (_outcome is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
_clock.Elapsed - _startedAt,
|
||||||
|
ShouldOfferFallback(kind) ? _dedicatedFallback : null,
|
||||||
|
peer);
|
||||||
|
State = terminalState;
|
||||||
|
if (kind == ConnectionOutcomeKind.Connected)
|
||||||
|
{
|
||||||
|
ConnectedPeer = peer;
|
||||||
|
}
|
||||||
|
Volatile.Write(ref _outcome, outcome);
|
||||||
|
ReleaseSubscriptions();
|
||||||
|
completion = new(terminalState, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
Completed?.Invoke(this, completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseSubscriptions()
|
||||||
|
{
|
||||||
|
lock (_completionGate)
|
||||||
|
{
|
||||||
|
if (_subscriptionsReleased)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
|
||||||
|
_networkEvents.RendezvousNetworkError -= OnNetworkError;
|
||||||
|
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
|
||||||
|
_subscriptionsReleased = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteManagerStopped() => Complete(
|
||||||
|
RendezvousConnectionState.ManagerStopped,
|
||||||
|
ConnectionOutcomeKind.ManagerStopped,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
CurrentPhase());
|
||||||
|
|
||||||
|
private RendezvousConnectionPhase CurrentPhase() => State switch
|
||||||
|
{
|
||||||
|
RendezvousConnectionState.Punching => RendezvousConnectionPhase.NatTraversal,
|
||||||
|
RendezvousConnectionState.Connecting => RendezvousConnectionPhase.DirectConnection,
|
||||||
|
_ => RendezvousConnectionPhase.Complete,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool ShouldOfferFallback(ConnectionOutcomeKind kind) => kind is not (
|
||||||
|
ConnectionOutcomeKind.Connected
|
||||||
|
or ConnectionOutcomeKind.Cancelled
|
||||||
|
or ConnectionOutcomeKind.Disposed);
|
||||||
|
|
||||||
|
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
|
||||||
|
left <= right ? left : right;
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum RendezvousConnectionState
|
||||||
|
{
|
||||||
|
Punching = 1,
|
||||||
|
Connecting = 2,
|
||||||
|
Connected = 3,
|
||||||
|
Cancelled = 4,
|
||||||
|
TimedOut = 5,
|
||||||
|
Rejected = 6,
|
||||||
|
ManagerStopped = 7,
|
||||||
|
Disposed = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousConnectionCompletedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
[Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
|
||||||
|
public RendezvousConnectionCompletedEventArgs(
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
NetPeer? peer)
|
||||||
|
: this(state, RendezvousCompletionInvariant.FromLegacy(state, peer))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousConnectionCompletedEventArgs(
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
RendezvousConnectionOutcome outcome)
|
||||||
|
{
|
||||||
|
RendezvousCompletionInvariant.Validate(state, outcome);
|
||||||
|
State = state;
|
||||||
|
Outcome = outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RendezvousConnectionState State { get; }
|
||||||
|
public RendezvousConnectionOutcome Outcome { get; }
|
||||||
|
public NetPeer? Peer => Outcome.Peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RendezvousCompletionInvariant
|
||||||
|
{
|
||||||
|
internal static RendezvousConnectionOutcome FromLegacy(
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
NetPeer? peer) => state switch
|
||||||
|
{
|
||||||
|
RendezvousConnectionState.Connected when peer is not null => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.Connected,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.None,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
TimeSpan.Zero,
|
||||||
|
peer: peer),
|
||||||
|
RendezvousConnectionState.Cancelled => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.Caller,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.TimedOut => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.DirectConnectTimedOut,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.Rejected => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.HostRejected,
|
||||||
|
RendezvousConnectionOutcomeSource.RemoteHost,
|
||||||
|
RendezvousConnectionFailureCategory.Authorization,
|
||||||
|
RendezvousConnectionPhase.Authorization,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.ManagerStopped => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.ManagerStopped,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.Disposed => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.Disposed,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.Connected => throw new ArgumentNullException(
|
||||||
|
nameof(peer),
|
||||||
|
"A connected completion requires a peer."),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(state),
|
||||||
|
state,
|
||||||
|
"A completion event requires a terminal connection state."),
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static void Validate(
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
RendezvousConnectionOutcome outcome)
|
||||||
|
{
|
||||||
|
if (outcome is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(outcome));
|
||||||
|
}
|
||||||
|
if ((state == RendezvousConnectionState.Connected) != outcome.IsSuccess)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The connection state and typed outcome contradict each other.",
|
||||||
|
nameof(outcome));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
public int MaximumPunchRequests { get; set; } = 5;
|
||||||
|
public int MaximumAttemptChecksPerPoll { get; set; } = 128;
|
||||||
|
public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||||
|
public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||||
|
public TimeSpan PunchTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
public TimeSpan DirectConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20);
|
||||||
|
public double JitterRatio { get; set; } = 0.2;
|
||||||
|
public NetworkEndpoint? DedicatedFallbackOverride { get; set; }
|
||||||
|
|
||||||
|
internal RendezvousCoordinatorOptions CopyAndValidate()
|
||||||
|
{
|
||||||
|
if (MaximumPunchRequests is < 1 or > 20
|
||||||
|
|| MaximumAttemptChecksPerPoll is < 1 or > 1_024
|
||||||
|
|| InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10)
|
||||||
|
|| MaximumPunchRetryDelay < InitialPunchRetryDelay
|
||||||
|
|| MaximumPunchRetryDelay > TimeSpan.FromSeconds(10)
|
||||||
|
|| PunchTimeout <= TimeSpan.Zero
|
||||||
|
|| PunchTimeout > TimeSpan.FromSeconds(30)
|
||||||
|
|| DirectConnectTimeout <= TimeSpan.Zero
|
||||||
|
|| DirectConnectTimeout > TimeSpan.FromSeconds(30)
|
||||||
|
|| ConnectionTicketLifetime <= TimeSpan.Zero
|
||||||
|
|| ConnectionTicketLifetime > TimeSpan.FromSeconds(20)
|
||||||
|
|| JitterRatio is < 0 or > 1
|
||||||
|
|| DedicatedFallbackOverride is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(DedicatedFallbackOverride))
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
MaximumPunchRequests = MaximumPunchRequests,
|
||||||
|
MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll,
|
||||||
|
InitialPunchRetryDelay = InitialPunchRetryDelay,
|
||||||
|
MaximumPunchRetryDelay = MaximumPunchRetryDelay,
|
||||||
|
PunchTimeout = PunchTimeout,
|
||||||
|
DirectConnectTimeout = DirectConnectTimeout,
|
||||||
|
ConnectionTicketLifetime = ConnectionTicketLifetime,
|
||||||
|
JitterRatio = JitterRatio,
|
||||||
|
DedicatedFallbackOverride = RendezvousEndpoint.Copy(DedicatedFallbackOverride),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IRendezvousCoordinatorClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
TimeSpan Elapsed { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock
|
||||||
|
{
|
||||||
|
private readonly long _origin = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
|
public TimeSpan Elapsed => TimeSpan.FromSeconds(
|
||||||
|
(Stopwatch.GetTimestamp() - _origin) / (double)Stopwatch.Frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RendezvousManagerGuard
|
||||||
|
{
|
||||||
|
internal static void Validate(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents)
|
||||||
|
{
|
||||||
|
networkEvents.ValidateManager(manager);
|
||||||
|
if (!manager.IsRunning)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The caller-owned LiteNetLib manager must be running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manager.NatPunchEnabled
|
||||||
|
|| manager.UnsyncedEvents
|
||||||
|
|| manager.NatPunchModule.UnsyncedEvents)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The caller-owned manager must enable NAT punching and synchronized event dispatch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RendezvousPunchRetrySchedule(
|
||||||
|
RendezvousCoordinatorOptions options,
|
||||||
|
IRendezvousCoordinatorClock clock)
|
||||||
|
{
|
||||||
|
public int RequestsSent { get; private set; }
|
||||||
|
public TimeSpan NextRequestAt { get; private set; } = TimeSpan.Zero;
|
||||||
|
|
||||||
|
public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests;
|
||||||
|
|
||||||
|
public bool IsDue(TimeSpan elapsed) => elapsed >= NextRequestAt;
|
||||||
|
|
||||||
|
public void RecordRequest()
|
||||||
|
{
|
||||||
|
int exponent = Math.Min(RequestsSent, 30);
|
||||||
|
RequestsSent++;
|
||||||
|
double milliseconds = Math.Min(
|
||||||
|
options.InitialPunchRetryDelay.TotalMilliseconds * Math.Pow(2, exponent),
|
||||||
|
options.MaximumPunchRetryDelay.TotalMilliseconds);
|
||||||
|
if (options.JitterRatio > 0)
|
||||||
|
{
|
||||||
|
Span<byte> random = stackalloc byte[1];
|
||||||
|
RandomNumberGenerator.Fill(random);
|
||||||
|
double unit = random[0] / 255d;
|
||||||
|
double multiplier = 1 - options.JitterRatio + (2 * options.JitterRatio * unit);
|
||||||
|
milliseconds = Math.Min(
|
||||||
|
milliseconds * multiplier,
|
||||||
|
options.MaximumPunchRetryDelay.TotalMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
NextRequestAt = clock.Elapsed + TimeSpan.FromMilliseconds(milliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum RendezvousHostState
|
||||||
|
{
|
||||||
|
Active = 1,
|
||||||
|
ManagerStopped = 2,
|
||||||
|
Disposed = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousHostAttemptCompletedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
[Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
|
||||||
|
public RendezvousHostAttemptCompletedEventArgs(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
NetPeer? peer)
|
||||||
|
: this(attemptId, state, RendezvousCompletionInvariant.FromLegacy(state, peer))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousHostAttemptCompletedEventArgs(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
RendezvousConnectionOutcome outcome)
|
||||||
|
{
|
||||||
|
if (attemptId.Value == Guid.Empty)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The completed attempt ID is invalid.", nameof(attemptId));
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousCompletionInvariant.Validate(state, outcome);
|
||||||
|
AttemptId = attemptId;
|
||||||
|
State = state;
|
||||||
|
Outcome = outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptId AttemptId { get; }
|
||||||
|
public RendezvousConnectionState State { get; }
|
||||||
|
public RendezvousConnectionOutcome Outcome { get; }
|
||||||
|
public NetPeer? Peer => Outcome.Peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousHostCoordinator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly NetManager _manager;
|
||||||
|
private readonly RendezvousNetListener _networkEvents;
|
||||||
|
private readonly EventBasedNatPunchListener _punchEvents;
|
||||||
|
private readonly IPEndPoint _mediator;
|
||||||
|
private readonly PublishedSession _session;
|
||||||
|
private readonly IRendezvousJoinClient _joinClient;
|
||||||
|
private readonly RendezvousCoordinatorOptions _options;
|
||||||
|
private readonly IRendezvousCoordinatorClock _clock;
|
||||||
|
private readonly ConnectionTicketValidator _tickets;
|
||||||
|
private readonly Dictionary<JoinAttemptId, PendingHostAttempt> _attempts = [];
|
||||||
|
private readonly Dictionary<NetPeer, JoinAttemptId> _acceptedPeers = [];
|
||||||
|
private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = [];
|
||||||
|
private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = [];
|
||||||
|
private readonly Queue<JoinAttemptId> _attemptSchedule = [];
|
||||||
|
private readonly SortedDictionary<long, Queue<HostAttemptDeadline>> _deadlines = [];
|
||||||
|
private readonly List<JoinAttemptId> _cleanupScratch = [];
|
||||||
|
private HostJoinAttempt[]? _latestSnapshot;
|
||||||
|
private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue;
|
||||||
|
private DateTimeOffset _nextTerminalCleanupAt = DateTimeOffset.MinValue;
|
||||||
|
private int _refreshing;
|
||||||
|
private int _polling;
|
||||||
|
private bool _subscriptionsReleased;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
public RendezvousHostCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
PublishedSession session,
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
RendezvousCoordinatorOptions? options = null)
|
||||||
|
: this(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
mediator,
|
||||||
|
session,
|
||||||
|
joinClient,
|
||||||
|
options,
|
||||||
|
new SystemRendezvousCoordinatorClock(),
|
||||||
|
null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousHostCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
PublishedSession session,
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
RendezvousCoordinatorOptions? options,
|
||||||
|
IRendezvousCoordinatorClock clock,
|
||||||
|
ConnectionTicketValidator? tickets)
|
||||||
|
{
|
||||||
|
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||||
|
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
|
||||||
|
_punchEvents = _networkEvents.PunchEvents;
|
||||||
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||||
|
_joinClient = joinClient ?? throw new ArgumentNullException(nameof(joinClient));
|
||||||
|
_options = (options ?? new RendezvousCoordinatorOptions()).CopyAndValidate();
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
_tickets = tickets ?? new ConnectionTicketValidator();
|
||||||
|
|
||||||
|
RendezvousManagerGuard.Validate(_manager, _networkEvents);
|
||||||
|
ValidateInputs();
|
||||||
|
_networkEvents.RendezvousConnectionRequest += OnConnectionRequest;
|
||||||
|
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
|
||||||
|
_networkEvents.RendezvousNetworkError += OnNetworkError;
|
||||||
|
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<RendezvousHostAttemptCompletedEventArgs>? AttemptCompleted;
|
||||||
|
|
||||||
|
public RendezvousHostState State { get; private set; } = RendezvousHostState.Active;
|
||||||
|
public int PendingAttemptCount => _attempts.Count;
|
||||||
|
internal int DeferredRequestCount => _deferredRequests.Count;
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<int>> RefreshJoinAttemptsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (Interlocked.Exchange(ref _refreshing, 1) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("A host invitation refresh is already running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
|
||||||
|
await _joinClient.BrowseAllForHostAsync(
|
||||||
|
_session,
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<int>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
HostJoinAttempt[] snapshot = result.Value.Select(CopyAttempt).ToArray();
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Exchange(ref _latestSnapshot, snapshot);
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _latestSnapshot, null);
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Success(snapshot.Length);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _refreshing, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Poll()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Interlocked.Exchange(ref _polling, 1) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ApplySnapshots();
|
||||||
|
if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
Stop(
|
||||||
|
RendezvousHostState.ManagerStopped,
|
||||||
|
RendezvousConnectionState.ManagerStopped,
|
||||||
|
ConnectionOutcomeKind.ManagerStopped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.PollEvents();
|
||||||
|
_manager.PollEvents();
|
||||||
|
_manager.NatPunchModule.PollEvents();
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
TimeSpan elapsed = _clock.Elapsed;
|
||||||
|
if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
Stop(
|
||||||
|
RendezvousHostState.ManagerStopped,
|
||||||
|
RendezvousConnectionState.ManagerStopped,
|
||||||
|
ConnectionOutcomeKind.ManagerStopped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshPresence(now);
|
||||||
|
ProcessDueDeadlines(elapsed);
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int checks = Math.Min(
|
||||||
|
_attemptSchedule.Count,
|
||||||
|
_options.MaximumAttemptChecksPerPoll);
|
||||||
|
for (int index = 0; index < checks; index++)
|
||||||
|
{
|
||||||
|
JoinAttemptId attemptId = _attemptSchedule.Dequeue();
|
||||||
|
if (!_attempts.TryGetValue(attemptId, out PendingHostAttempt? attempt))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.State != RendezvousConnectionState.Punching)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.Retry.IsDue(elapsed))
|
||||||
|
{
|
||||||
|
if (attempt.Retry.IsExhausted)
|
||||||
|
{
|
||||||
|
CompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.NatTraversal,
|
||||||
|
RendezvousConnectionPhase.NatTraversal);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
_mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
attempt.Invitation.MediationHandle,
|
||||||
|
attempt.Invitation.HostPunchCapability));
|
||||||
|
attempt.Retry.RecordRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
_attemptSchedule.Enqueue(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= _nextTerminalCleanupAt)
|
||||||
|
{
|
||||||
|
_cleanupScratch.Clear();
|
||||||
|
foreach (KeyValuePair<JoinAttemptId, DateTimeOffset> terminal in _terminalAttempts)
|
||||||
|
{
|
||||||
|
if (terminal.Value <= now)
|
||||||
|
{
|
||||||
|
_cleanupScratch.Add(terminal.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JoinAttemptId attemptId in _cleanupScratch)
|
||||||
|
{
|
||||||
|
_terminalAttempts.Remove(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextTerminalCleanupAt = now + TimeSpan.FromSeconds(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _polling, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stop(
|
||||||
|
RendezvousHostState.Disposed,
|
||||||
|
RendezvousConnectionState.Disposed,
|
||||||
|
ConnectionOutcomeKind.Disposed);
|
||||||
|
Interlocked.Exchange(ref _latestSnapshot, null);
|
||||||
|
_attemptSchedule.Clear();
|
||||||
|
_deadlines.Clear();
|
||||||
|
_terminalAttempts.Clear();
|
||||||
|
_cleanupScratch.Clear();
|
||||||
|
_tickets.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[RendezvousHostCoordinator {_session.ListingId}; credentials redacted]";
|
||||||
|
|
||||||
|
private void ApplySnapshots()
|
||||||
|
{
|
||||||
|
HostJoinAttempt[]? latest = Interlocked.Exchange(ref _latestSnapshot, null);
|
||||||
|
|
||||||
|
if (latest is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
TimeSpan elapsed = _clock.Elapsed;
|
||||||
|
foreach (HostJoinAttempt invitation in latest)
|
||||||
|
{
|
||||||
|
if (invitation.AttemptId.Value == Guid.Empty
|
||||||
|
|| invitation.MediationHandle.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(invitation.HostPunchCapability)
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(
|
||||||
|
invitation.ConnectionTicketDigest))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.IsCancelled)
|
||||||
|
{
|
||||||
|
if (_attempts.ContainsKey(invitation.AttemptId))
|
||||||
|
{
|
||||||
|
CompleteAttempt(
|
||||||
|
invitation.AttemptId,
|
||||||
|
RendezvousConnectionState.Cancelled,
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.RendezvousService,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Authorization);
|
||||||
|
}
|
||||||
|
|
||||||
|
_terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.ExpiresAt <= now
|
||||||
|
|| _attempts.ContainsKey(invitation.AttemptId)
|
||||||
|
|| _terminalAttempts.ContainsKey(invitation.AttemptId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan attemptDeadline = elapsed + (invitation.ExpiresAt - now);
|
||||||
|
TimeSpan punchDeadline = Min(
|
||||||
|
attemptDeadline,
|
||||||
|
elapsed + _options.PunchTimeout);
|
||||||
|
_attempts.Add(
|
||||||
|
invitation.AttemptId,
|
||||||
|
new PendingHostAttempt(
|
||||||
|
CopyAttempt(invitation),
|
||||||
|
new RendezvousPunchRetrySchedule(_options, _clock),
|
||||||
|
elapsed,
|
||||||
|
attemptDeadline,
|
||||||
|
punchDeadline));
|
||||||
|
EnqueueDeadline(
|
||||||
|
new HostAttemptDeadline(
|
||||||
|
invitation.AttemptId,
|
||||||
|
RendezvousConnectionState.Punching,
|
||||||
|
punchDeadline));
|
||||||
|
_attemptSchedule.Enqueue(invitation.AttemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshPresence(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (now < _nextPresenceAt || now >= _session.ExpiresAt)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
_mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.HostPresence,
|
||||||
|
_session.HostPresenceHandle,
|
||||||
|
_session.HostPresenceCapability));
|
||||||
|
_nextPresenceAt = now + TimeSpan.FromSeconds(_session.HostPresenceRefreshAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNatIntroductionSuccess(
|
||||||
|
IPEndPoint target,
|
||||||
|
NatAddressType addressType,
|
||||||
|
string encodedIntroduction)
|
||||||
|
{
|
||||||
|
_ = target;
|
||||||
|
_ = addressType;
|
||||||
|
if (!NatIntroductionTokenCodec.TryDecode(
|
||||||
|
encodedIntroduction,
|
||||||
|
out NatIntroductionToken? introduction)
|
||||||
|
|| introduction is null
|
||||||
|
|| !_attempts.TryGetValue(introduction.AttemptId, out PendingHostAttempt? attempt)
|
||||||
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
attempt.Invitation.ConnectionTicketDigest)
|
||||||
|
|| !_tickets.TryAuthorize(
|
||||||
|
introduction.AttemptId,
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
Min(
|
||||||
|
attempt.Invitation.ExpiresAt,
|
||||||
|
_clock.UtcNow + _options.ConnectionTicketLifetime)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt.State = RendezvousConnectionState.Connecting;
|
||||||
|
attempt.DirectDeadline = Min(
|
||||||
|
attempt.AttemptDeadline,
|
||||||
|
_clock.Elapsed + _options.DirectConnectTimeout);
|
||||||
|
EnqueueDeadline(new HostAttemptDeadline(
|
||||||
|
introduction.AttemptId,
|
||||||
|
RendezvousConnectionState.Connecting,
|
||||||
|
attempt.DirectDeadline.Value));
|
||||||
|
if (_deferredRequests.Remove(
|
||||||
|
introduction.AttemptId,
|
||||||
|
out DeferredConnectionRequest? deferred))
|
||||||
|
{
|
||||||
|
AcceptAuthorizedRequest(
|
||||||
|
introduction.AttemptId,
|
||||||
|
attempt,
|
||||||
|
deferred.Request,
|
||||||
|
deferred.ConnectionTicket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConnectionRequest(ConnectionRequest request)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<byte> data = request.Data.GetRemainingBytesSpan();
|
||||||
|
if (!DirectConnectionRequestCodec.IsRendezvousRequest(data))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DirectConnectionRequestCodec.TryDecode(data, out DirectConnectionRequest? connection)
|
||||||
|
|| connection is null
|
||||||
|
|| !_attempts.TryGetValue(connection.AttemptId, out PendingHostAttempt? attempt)
|
||||||
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||||
|
connection.ConnectionTicket,
|
||||||
|
attempt.Invitation.ConnectionTicketDigest))
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.State == RendezvousConnectionState.Punching)
|
||||||
|
{
|
||||||
|
_deferredRequests[connection.AttemptId] = new(
|
||||||
|
request,
|
||||||
|
connection.ConnectionTicket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.State != RendezvousConnectionState.Connecting)
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AcceptAuthorizedRequest(
|
||||||
|
connection.AttemptId,
|
||||||
|
attempt,
|
||||||
|
request,
|
||||||
|
connection.ConnectionTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerConnected(NetPeer peer)
|
||||||
|
{
|
||||||
|
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
CompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
RendezvousConnectionState.Connected,
|
||||||
|
ConnectionOutcomeKind.Connected,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.None,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||||
|
{
|
||||||
|
_ = disconnectInfo;
|
||||||
|
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
|
||||||
|
? ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
: ConnectionOutcomeKind.TransportError;
|
||||||
|
CompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
kind == ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
? RendezvousConnectionState.TimedOut
|
||||||
|
: RendezvousConnectionState.Rejected,
|
||||||
|
kind,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
|
||||||
|
{
|
||||||
|
_ = socketError;
|
||||||
|
if (!endpoint.Equals(_mediator))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JoinAttemptId attemptId in _attempts
|
||||||
|
.Where(static item => item.Value.State == RendezvousConnectionState.Punching)
|
||||||
|
.Select(static item => item.Key)
|
||||||
|
.ToArray())
|
||||||
|
{
|
||||||
|
CompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
ConnectionOutcomeKind.MediatorUnavailable,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.Mediation,
|
||||||
|
RendezvousConnectionPhase.Mediation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteAttempt(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
NetPeer? peer = null)
|
||||||
|
{
|
||||||
|
if (TryCompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
state,
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
peer,
|
||||||
|
out RendezvousHostAttemptCompletedEventArgs? completion))
|
||||||
|
{
|
||||||
|
AttemptCompleted?.Invoke(this, completion!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryCompleteAttempt(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
NetPeer? peer,
|
||||||
|
out RendezvousHostAttemptCompletedEventArgs? completion)
|
||||||
|
{
|
||||||
|
completion = null;
|
||||||
|
if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.AcceptedPeer is not null)
|
||||||
|
{
|
||||||
|
_acceptedPeers.Remove(attempt.AcceptedPeer);
|
||||||
|
if (kind != ConnectionOutcomeKind.Connected)
|
||||||
|
{
|
||||||
|
attempt.AcceptedPeer.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_deferredRequests.Remove(attemptId, out DeferredConnectionRequest? deferred))
|
||||||
|
{
|
||||||
|
deferred.Request.RejectForce([]);
|
||||||
|
}
|
||||||
|
_tickets.Revoke(attemptId);
|
||||||
|
_terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt;
|
||||||
|
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
_clock.Elapsed - attempt.StartedAt,
|
||||||
|
peer: peer);
|
||||||
|
completion = new(attemptId, state, outcome);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Stop(
|
||||||
|
RendezvousHostState hostState,
|
||||||
|
RendezvousConnectionState attemptState,
|
||||||
|
ConnectionOutcomeKind outcomeKind)
|
||||||
|
{
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State = hostState;
|
||||||
|
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
|
||||||
|
foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray())
|
||||||
|
{
|
||||||
|
RendezvousConnectionPhase phase = _attempts[attemptId].State
|
||||||
|
== RendezvousConnectionState.Connecting
|
||||||
|
? RendezvousConnectionPhase.DirectConnection
|
||||||
|
: RendezvousConnectionPhase.NatTraversal;
|
||||||
|
if (TryCompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
attemptState,
|
||||||
|
outcomeKind,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
phase,
|
||||||
|
null,
|
||||||
|
out RendezvousHostAttemptCompletedEventArgs? completion))
|
||||||
|
{
|
||||||
|
completions.Add(completion!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseSubscriptions();
|
||||||
|
foreach (RendezvousHostAttemptCompletedEventArgs completion in completions)
|
||||||
|
{
|
||||||
|
AttemptCompleted?.Invoke(this, completion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseSubscriptions()
|
||||||
|
{
|
||||||
|
if (_subscriptionsReleased)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_networkEvents.RendezvousConnectionRequest -= OnConnectionRequest;
|
||||||
|
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
|
||||||
|
_networkEvents.RendezvousNetworkError -= OnNetworkError;
|
||||||
|
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
|
||||||
|
_subscriptionsReleased = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateInputs()
|
||||||
|
{
|
||||||
|
if (_mediator.Port is < 1 or > 65_535
|
||||||
|
|| _session.HostPresenceHandle.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(_session.HostPresenceCapability)
|
||||||
|
|| _session.HostPresenceRefreshAfterSeconds < 1
|
||||||
|
|| _session.ExpiresAt <= _clock.UtcNow)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The host traversal inputs are invalid.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HostJoinAttempt CopyAttempt(HostJoinAttempt attempt) => new()
|
||||||
|
{
|
||||||
|
AttemptId = attempt.AttemptId,
|
||||||
|
MediationHandle = attempt.MediationHandle,
|
||||||
|
HostPunchCapability = attempt.HostPunchCapability,
|
||||||
|
ConnectionTicketDigest = attempt.ConnectionTicketDigest,
|
||||||
|
IsCancelled = attempt.IsCancelled,
|
||||||
|
ExpiresAt = attempt.ExpiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
|
||||||
|
left <= right ? left : right;
|
||||||
|
|
||||||
|
private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) =>
|
||||||
|
left <= right ? left : right;
|
||||||
|
|
||||||
|
private void EnqueueDeadline(HostAttemptDeadline deadline)
|
||||||
|
{
|
||||||
|
if (!_deadlines.TryGetValue(deadline.Deadline.Ticks, out Queue<HostAttemptDeadline>? bucket))
|
||||||
|
{
|
||||||
|
bucket = new Queue<HostAttemptDeadline>();
|
||||||
|
_deadlines.Add(deadline.Deadline.Ticks, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.Enqueue(deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessDueDeadlines(TimeSpan elapsed)
|
||||||
|
{
|
||||||
|
while (_deadlines.Count > 0)
|
||||||
|
{
|
||||||
|
KeyValuePair<long, Queue<HostAttemptDeadline>> first = _deadlines.First();
|
||||||
|
if (first.Key > elapsed.Ticks)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HostAttemptDeadline deadline = first.Value.Dequeue();
|
||||||
|
if (first.Value.Count == 0)
|
||||||
|
{
|
||||||
|
_deadlines.Remove(first.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_attempts.TryGetValue(deadline.AttemptId, out PendingHostAttempt? attempt)
|
||||||
|
|| attempt.State != deadline.ExpectedState
|
||||||
|
|| (deadline.ExpectedState == RendezvousConnectionState.Punching
|
||||||
|
? attempt.PunchDeadline
|
||||||
|
: attempt.DirectDeadline) != deadline.Deadline)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool expired = elapsed >= attempt.AttemptDeadline;
|
||||||
|
CompleteAttempt(
|
||||||
|
deadline.AttemptId,
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
expired
|
||||||
|
? ConnectionOutcomeKind.AttemptExpired
|
||||||
|
: deadline.ExpectedState == RendezvousConnectionState.Punching
|
||||||
|
? ConnectionOutcomeKind.PunchTimedOut
|
||||||
|
: ConnectionOutcomeKind.DirectConnectTimedOut,
|
||||||
|
expired
|
||||||
|
? RendezvousConnectionOutcomeSource.RendezvousService
|
||||||
|
: RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
expired
|
||||||
|
? RendezvousConnectionFailureCategory.Authorization
|
||||||
|
: deadline.ExpectedState == RendezvousConnectionState.Punching
|
||||||
|
? RendezvousConnectionFailureCategory.NatTraversal
|
||||||
|
: RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
expired
|
||||||
|
? RendezvousConnectionPhase.Authorization
|
||||||
|
: deadline.ExpectedState == RendezvousConnectionState.Punching
|
||||||
|
? RendezvousConnectionPhase.NatTraversal
|
||||||
|
: RendezvousConnectionPhase.DirectConnection);
|
||||||
|
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AcceptAuthorizedRequest(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
PendingHostAttempt attempt,
|
||||||
|
ConnectionRequest request,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
ConnectionTicketConsumptionResult consumption = _tickets.Consume(
|
||||||
|
attemptId,
|
||||||
|
connectionTicket);
|
||||||
|
if (consumption != ConnectionTicketConsumptionResult.Accepted)
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NetPeer peer = request.Accept();
|
||||||
|
attempt.AcceptedPeer = peer;
|
||||||
|
_acceptedPeers[peer] = attemptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PendingHostAttempt(
|
||||||
|
HostJoinAttempt invitation,
|
||||||
|
RendezvousPunchRetrySchedule retry,
|
||||||
|
TimeSpan startedAt,
|
||||||
|
TimeSpan attemptDeadline,
|
||||||
|
TimeSpan punchDeadline)
|
||||||
|
{
|
||||||
|
internal HostJoinAttempt Invitation { get; } = invitation;
|
||||||
|
internal RendezvousPunchRetrySchedule Retry { get; } = retry;
|
||||||
|
internal TimeSpan StartedAt { get; } = startedAt;
|
||||||
|
internal TimeSpan AttemptDeadline { get; } = attemptDeadline;
|
||||||
|
internal TimeSpan PunchDeadline { get; } = punchDeadline;
|
||||||
|
internal TimeSpan? DirectDeadline { get; set; }
|
||||||
|
internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching;
|
||||||
|
internal NetPeer? AcceptedPeer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HostAttemptDeadline(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState expectedState,
|
||||||
|
TimeSpan deadline)
|
||||||
|
{
|
||||||
|
internal JoinAttemptId AttemptId { get; } = attemptId;
|
||||||
|
internal RendezvousConnectionState ExpectedState { get; } = expectedState;
|
||||||
|
internal TimeSpan Deadline { get; } = deadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DeferredConnectionRequest(
|
||||||
|
ConnectionRequest request,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
internal ConnectionRequest Request { get; } = request;
|
||||||
|
internal string ConnectionTicket { get; } = connectionTicket;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using LiteNetLib;
|
||||||
|
using LiteNetLib.Utils;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousNetListener : INetEventListener
|
||||||
|
{
|
||||||
|
private NetManager? _manager;
|
||||||
|
|
||||||
|
public EventBasedNetListener GameplayEvents { get; } = new();
|
||||||
|
public EventBasedNatPunchListener PunchEvents { get; } = new();
|
||||||
|
|
||||||
|
public NetManager CreateManager()
|
||||||
|
{
|
||||||
|
if (_manager is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"This Rendezvous listener is already bound to a LiteNetLib manager.");
|
||||||
|
}
|
||||||
|
|
||||||
|
NetManager manager = new(this) { NatPunchEnabled = true };
|
||||||
|
manager.NatPunchModule.Init(PunchEvents);
|
||||||
|
_manager = manager;
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal event Action<NetPeer>? RendezvousPeerConnected;
|
||||||
|
internal event Action<NetPeer, DisconnectInfo>? RendezvousPeerDisconnected;
|
||||||
|
internal event Action<ConnectionRequest>? RendezvousConnectionRequest;
|
||||||
|
internal event Action<IPEndPoint, SocketError>? RendezvousNetworkError;
|
||||||
|
|
||||||
|
internal void ValidateManager(NetManager manager)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(_manager, manager))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The LiteNetLib manager must be created by this Rendezvous listener.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPeerConnected(NetPeer peer)
|
||||||
|
{
|
||||||
|
RendezvousPeerConnected?.Invoke(peer);
|
||||||
|
((INetEventListener)GameplayEvents).OnPeerConnected(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||||
|
{
|
||||||
|
RendezvousPeerDisconnected?.Invoke(peer, disconnectInfo);
|
||||||
|
((INetEventListener)GameplayEvents).OnPeerDisconnected(peer, disconnectInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnNetworkError(IPEndPoint endPoint, SocketError socketError)
|
||||||
|
{
|
||||||
|
RendezvousNetworkError?.Invoke(endPoint, socketError);
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkError(endPoint, socketError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnNetworkReceive(
|
||||||
|
NetPeer peer,
|
||||||
|
NetPacketReader reader,
|
||||||
|
byte channelNumber,
|
||||||
|
DeliveryMethod deliveryMethod) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkReceive(
|
||||||
|
peer,
|
||||||
|
reader,
|
||||||
|
channelNumber,
|
||||||
|
deliveryMethod);
|
||||||
|
|
||||||
|
public void OnNetworkReceiveUnconnected(
|
||||||
|
IPEndPoint remoteEndPoint,
|
||||||
|
NetPacketReader reader,
|
||||||
|
UnconnectedMessageType messageType) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkReceiveUnconnected(
|
||||||
|
remoteEndPoint,
|
||||||
|
reader,
|
||||||
|
messageType);
|
||||||
|
|
||||||
|
public void OnNetworkLatencyUpdate(NetPeer peer, int latency) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkLatencyUpdate(peer, latency);
|
||||||
|
|
||||||
|
public void OnConnectionRequest(ConnectionRequest request)
|
||||||
|
{
|
||||||
|
int position = request.Data.Position;
|
||||||
|
bool isRendezvous = DirectConnectionRequestCodec.IsRendezvousRequest(
|
||||||
|
request.Data.GetRemainingBytesSpan());
|
||||||
|
request.Data.SetPosition(position);
|
||||||
|
if (!isRendezvous)
|
||||||
|
{
|
||||||
|
((INetEventListener)GameplayEvents).OnConnectionRequest(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Action<ConnectionRequest>? handler = RendezvousConnectionRequest;
|
||||||
|
if (handler is null)
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMessageDelivered(NetPeer peer, object userData) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnMessageDelivered(peer, userData);
|
||||||
|
|
||||||
|
public void OnNtpResponse(NtpPacket packet) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNtpResponse(packet);
|
||||||
|
|
||||||
|
public void OnPeerAddressChanged(NetPeer peer, IPEndPoint previousAddress) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnPeerAddressChanged(peer, previousAddress);
|
||||||
|
}
|
||||||
@@ -49,6 +49,27 @@ public enum ConnectionOutcomeKind
|
|||||||
HostRejected = 7,
|
HostRejected = 7,
|
||||||
TransportFailed = 8,
|
TransportFailed = 8,
|
||||||
FallbackOffered = 9,
|
FallbackOffered = 9,
|
||||||
|
DirectoryNotFound = 10,
|
||||||
|
AttemptExpired = 11,
|
||||||
|
Unauthorized = 12,
|
||||||
|
RateLimited = 13,
|
||||||
|
NoHostPresence = 14,
|
||||||
|
ServiceUnavailable = 15,
|
||||||
|
MediatorUnavailable = 16,
|
||||||
|
PunchTimedOut = 17,
|
||||||
|
DirectConnectTimedOut = 18,
|
||||||
|
TransportError = 19,
|
||||||
|
ManagerStopped = 20,
|
||||||
|
Disposed = 21,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ConnectionElapsedBucket
|
||||||
|
{
|
||||||
|
UnderOneSecond = 1,
|
||||||
|
OneToFiveSeconds = 2,
|
||||||
|
FiveToFifteenSeconds = 3,
|
||||||
|
FifteenToThirtySeconds = 4,
|
||||||
|
ThirtySecondsOrMore = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum UdpPresenceMessageType : byte
|
public enum UdpPresenceMessageType : byte
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public static class ContractLimits
|
|||||||
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
|
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
|
||||||
public const int UdpCapabilityMaxCharacters = 192;
|
public const int UdpCapabilityMaxCharacters = 192;
|
||||||
public const int ConnectionTicketMaxCharacters = 192;
|
public const int ConnectionTicketMaxCharacters = 192;
|
||||||
|
public const int DerivedCredentialCharacters = 43;
|
||||||
|
public const int NatPunchRequestTokenCharacters = 192;
|
||||||
public const int LiteNetLibNatTokenMaxCharacters = 256;
|
public const int LiteNetLibNatTokenMaxCharacters = 256;
|
||||||
public const int SessionCapacityMaxPlayers = 10_000;
|
public const int SessionCapacityMaxPlayers = 10_000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,30 @@ public static class ContractValidation
|
|||||||
public static bool IsDiagnosticCodeValid(string? value) =>
|
public static bool IsDiagnosticCodeValid(string? value) =>
|
||||||
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
|
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
|
||||||
|
|
||||||
|
public static bool IsReportableConnectionOutcome(ConnectionOutcomeKind outcome) => outcome is
|
||||||
|
ConnectionOutcomeKind.Connected
|
||||||
|
or ConnectionOutcomeKind.Cancelled
|
||||||
|
or ConnectionOutcomeKind.TimedOut
|
||||||
|
or ConnectionOutcomeKind.StaleHost
|
||||||
|
or ConnectionOutcomeKind.TransportFailed
|
||||||
|
or ConnectionOutcomeKind.FallbackOffered
|
||||||
|
or ConnectionOutcomeKind.AttemptExpired
|
||||||
|
or ConnectionOutcomeKind.NoHostPresence
|
||||||
|
or ConnectionOutcomeKind.MediatorUnavailable
|
||||||
|
or ConnectionOutcomeKind.PunchTimedOut
|
||||||
|
or ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
or ConnectionOutcomeKind.HostRejected
|
||||||
|
or ConnectionOutcomeKind.TransportError
|
||||||
|
or ConnectionOutcomeKind.ManagerStopped
|
||||||
|
or ConnectionOutcomeKind.Disposed;
|
||||||
|
|
||||||
public static bool IsBuildVersionValid(string? value) =>
|
public static bool IsBuildVersionValid(string? value) =>
|
||||||
IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
|
!string.IsNullOrWhiteSpace(value)
|
||||||
|
&& IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
|
||||||
|
|
||||||
public static bool IsDisplayNameValid(string? value) =>
|
public static bool IsDisplayNameValid(string? value) =>
|
||||||
IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
|
!string.IsNullOrWhiteSpace(value)
|
||||||
|
&& IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
|
||||||
|
|
||||||
public static bool IsOpaqueHttpCredentialValid(string? value) =>
|
public static bool IsOpaqueHttpCredentialValid(string? value) =>
|
||||||
value is not null
|
value is not null
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
public sealed class ReportConnectionOutcomeRequest
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public ConnectionOutcomeKind Outcome { get; set; }
|
||||||
|
|
||||||
|
public ConnectionElapsedBucket ElapsedBucket { get; set; }
|
||||||
|
|
||||||
|
[Obsolete("Use ElapsedBucket. Exact elapsed time is accepted only for v1 compatibility and is not retained.")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||||
|
public int ElapsedMilliseconds { get; set; }
|
||||||
|
|
||||||
|
[Obsolete("Diagnostic codes are accepted only for v1 compatibility and are not retained.")]
|
||||||
|
public string? DiagnosticCode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReportConnectionOutcomeResponse
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool Accepted { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool IsDuplicate { get; set; }
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ public sealed class CreateJoinAttemptResponse
|
|||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string ClientPunchCapability { get; set; } = string.Empty;
|
public string ClientPunchCapability { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string ConnectionTicketDigest { get; set; } = string.Empty;
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
public NetworkEndpoint? DedicatedFallback { get; set; }
|
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||||
@@ -53,6 +56,12 @@ public sealed class HostJoinAttempt
|
|||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string HostPunchCapability { get; set; } = string.Empty;
|
public string HostPunchCapability { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string ConnectionTicketDigest { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool IsCancelled { get; set; }
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -67,26 +76,3 @@ public sealed class BrowseHostJoinAttemptsResponse
|
|||||||
|
|
||||||
public string? NextCursor { get; set; }
|
public string? NextCursor { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ReportConnectionOutcomeRequest
|
|
||||||
{
|
|
||||||
[JsonRequired]
|
|
||||||
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
|
||||||
|
|
||||||
[JsonRequired]
|
|
||||||
public ConnectionOutcomeKind Outcome { get; set; }
|
|
||||||
|
|
||||||
[JsonRequired]
|
|
||||||
public int ElapsedMilliseconds { get; set; }
|
|
||||||
|
|
||||||
public string? DiagnosticCode { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ReportConnectionOutcomeResponse
|
|
||||||
{
|
|
||||||
[JsonRequired]
|
|
||||||
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
|
||||||
|
|
||||||
[JsonRequired]
|
|
||||||
public bool Accepted { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ public sealed class SessionListing
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RegisterSessionRequest
|
public sealed class RegisterSessionRequest
|
||||||
@@ -75,6 +77,8 @@ public sealed class RegisterSessionRequest
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RegisterSessionResponse
|
public sealed class RegisterSessionResponse
|
||||||
@@ -99,6 +103,12 @@ public sealed class RegisterSessionResponse
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public int LeaseRenewAfterSeconds { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public int HostPresenceRefreshAfterSeconds { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RenewLeaseRequest
|
public sealed class RenewLeaseRequest
|
||||||
@@ -117,6 +127,9 @@ public sealed class RenewLeaseResponse
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public int RenewAfterSeconds { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpdateSessionRequest
|
public sealed class UpdateSessionRequest
|
||||||
@@ -138,6 +151,8 @@ public sealed class UpdateSessionRequest
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DeleteSessionRequest
|
public sealed class DeleteSessionRequest
|
||||||
@@ -165,6 +180,8 @@ public sealed class BrowseSessionsRequest
|
|||||||
public RegionId? RegionId { get; set; }
|
public RegionId? RegionId { get; set; }
|
||||||
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
|
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
|
||||||
|
|
||||||
|
public bool ExcludeFull { get; set; }
|
||||||
|
|
||||||
public string? Cursor { get; set; }
|
public string? Cursor { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ public static class ContractJson
|
|||||||
|
|
||||||
options.AllowTrailingCommas = false;
|
options.AllowTrailingCommas = false;
|
||||||
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||||
options.MaxDepth = 8;
|
// Nine is the minimum that lets ASP.NET generate the nullable fallback
|
||||||
|
// OpenAPI schema; the 16 KiB HTTP body limit still bounds parser work.
|
||||||
|
options.MaxDepth = 9;
|
||||||
options.NumberHandling = JsonNumberHandling.Strict;
|
options.NumberHandling = JsonNumberHandling.Strict;
|
||||||
options.PropertyNameCaseInsensitive = false;
|
options.PropertyNameCaseInsensitive = false;
|
||||||
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
public sealed class NatIntroductionToken
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId { get; set; }
|
||||||
|
public string ConnectionTicket { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[NatIntroductionToken {AttemptId}; ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NatIntroductionTokenCodec
|
||||||
|
{
|
||||||
|
public const int EncodedLength = ContractLimits.DerivedCredentialCharacters;
|
||||||
|
|
||||||
|
private const int DecodedLength = 32;
|
||||||
|
private const int AttemptIdLength = 16;
|
||||||
|
private const int AuthenticatorLength = DecodedLength - AttemptIdLength;
|
||||||
|
|
||||||
|
public static string Encode(JoinAttemptId attemptId, string derivedAuthenticator)
|
||||||
|
{
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(derivedAuthenticator)
|
||||||
|
|| !TryDecodeBase64Url(derivedAuthenticator, out byte[]? authenticator)
|
||||||
|
|| authenticator.Length != DecodedLength)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The NAT introduction token fields are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] payload = new byte[DecodedLength];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!attemptId.Value.TryWriteBytes(payload.AsSpan(0, AttemptIdLength)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticator.AsSpan(0, AuthenticatorLength).CopyTo(payload.AsSpan(AttemptIdLength));
|
||||||
|
return EncodeBase64Url(payload);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(authenticator);
|
||||||
|
CryptographicOperations.ZeroMemory(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(string? encoded, out NatIntroductionToken? token)
|
||||||
|
{
|
||||||
|
token = null;
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(encoded)
|
||||||
|
|| !TryDecodeBase64Url(encoded!, out byte[]? payload)
|
||||||
|
|| payload.Length != DecodedLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Guid attemptId = new(payload.AsSpan(0, AttemptIdLength));
|
||||||
|
if (attemptId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = new NatIntroductionToken
|
||||||
|
{
|
||||||
|
AttemptId = new JoinAttemptId(attemptId),
|
||||||
|
ConnectionTicket = encoded!,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ComputeDigest(string connectionTicket)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The connection ticket is invalid.", nameof(connectionTicket));
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] encoded = Encoding.ASCII.GetBytes(connectionTicket);
|
||||||
|
byte[] digest;
|
||||||
|
using (SHA256 sha256 = SHA256.Create())
|
||||||
|
{
|
||||||
|
digest = sha256.ComputeHash(encoded);
|
||||||
|
}
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return EncodeBase64Url(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool MatchesDigest(string? connectionTicket, string? expectedDigest)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(connectionTicket)
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(expectedDigest))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] actual = Encoding.ASCII.GetBytes(ComputeDigest(connectionTicket!));
|
||||||
|
byte[] expected = Encoding.ASCII.GetBytes(expectedDigest!);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(actual);
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDecodeBase64Url(string? encoded, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
if (encoded is null || encoded.Length != EncodedLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(
|
||||||
|
encoded.Replace('-', '+').Replace('_', '/') + "=");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EncodeBase64Url(byte[] value) =>
|
||||||
|
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
public enum NatPunchPeerRole
|
||||||
|
{
|
||||||
|
HostPresence = 1,
|
||||||
|
Host = 2,
|
||||||
|
Client = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NatPunchRequestToken
|
||||||
|
{
|
||||||
|
public NatPunchPeerRole Role { get; set; }
|
||||||
|
public MediationHandle MediationHandle { get; set; }
|
||||||
|
public string Capability { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override string ToString() => "[NatPunchRequestToken: capability redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NatPunchRequestTokenCodec
|
||||||
|
{
|
||||||
|
public const int EncodedLength = ContractLimits.NatPunchRequestTokenCharacters;
|
||||||
|
|
||||||
|
private const string VersionPrefix = "rv1:";
|
||||||
|
private const int HandleLength = 32;
|
||||||
|
private const int CapabilityLength = ContractLimits.DerivedCredentialCharacters;
|
||||||
|
private const char Separator = ':';
|
||||||
|
private const char Padding = '.';
|
||||||
|
|
||||||
|
public static string Encode(
|
||||||
|
NatPunchPeerRole role,
|
||||||
|
MediationHandle mediationHandle,
|
||||||
|
string capability)
|
||||||
|
{
|
||||||
|
if (!TryGetRoleCode(role, out char roleCode)
|
||||||
|
|| mediationHandle.Value == Guid.Empty
|
||||||
|
|| capability is null
|
||||||
|
|| capability.Length != CapabilityLength
|
||||||
|
|| !ContractValidation.IsCapabilityValid(capability))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The NAT punch request token fields are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string payload = string.Concat(
|
||||||
|
VersionPrefix,
|
||||||
|
roleCode,
|
||||||
|
Separator,
|
||||||
|
mediationHandle.Value.ToString("N"),
|
||||||
|
Separator,
|
||||||
|
capability);
|
||||||
|
return payload.PadRight(EncodedLength, Padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(string? encoded, out NatPunchRequestToken? token)
|
||||||
|
{
|
||||||
|
token = null;
|
||||||
|
if (encoded is null
|
||||||
|
|| encoded.Length != EncodedLength
|
||||||
|
|| !encoded.StartsWith(VersionPrefix, StringComparison.Ordinal)
|
||||||
|
|| !TryParseRole(encoded[VersionPrefix.Length], out NatPunchPeerRole role))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int roleSeparator = VersionPrefix.Length + 1;
|
||||||
|
int handleOffset = roleSeparator + 1;
|
||||||
|
int capabilitySeparator = handleOffset + HandleLength;
|
||||||
|
int capabilityOffset = capabilitySeparator + 1;
|
||||||
|
int paddingOffset = capabilityOffset + CapabilityLength;
|
||||||
|
string handleText = encoded.Substring(handleOffset, HandleLength);
|
||||||
|
if (encoded[roleSeparator] != Separator
|
||||||
|
|| encoded[capabilitySeparator] != Separator
|
||||||
|
|| !Guid.TryParseExact(handleText, "N", out Guid handle)
|
||||||
|
|| handle == Guid.Empty
|
||||||
|
|| !string.Equals(handleText, handle.ToString("N"), StringComparison.Ordinal)
|
||||||
|
|| !ContainsOnlyPadding(encoded, paddingOffset))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string capability = encoded.Substring(capabilityOffset, CapabilityLength);
|
||||||
|
if (!ContractValidation.IsCapabilityValid(capability))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = new NatPunchRequestToken
|
||||||
|
{
|
||||||
|
Role = role,
|
||||||
|
MediationHandle = new MediationHandle(handle),
|
||||||
|
Capability = capability,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetRoleCode(NatPunchPeerRole role, out char code)
|
||||||
|
{
|
||||||
|
code = role switch
|
||||||
|
{
|
||||||
|
NatPunchPeerRole.HostPresence => 'p',
|
||||||
|
NatPunchPeerRole.Host => 'h',
|
||||||
|
NatPunchPeerRole.Client => 'c',
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
return code != default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseRole(char code, out NatPunchPeerRole role)
|
||||||
|
{
|
||||||
|
role = code switch
|
||||||
|
{
|
||||||
|
'p' => NatPunchPeerRole.HostPresence,
|
||||||
|
'h' => NatPunchPeerRole.Host,
|
||||||
|
'c' => NatPunchPeerRole.Client,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
return role != default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsOnlyPadding(string value, int offset)
|
||||||
|
{
|
||||||
|
for (int index = offset; index < value.Length; index++)
|
||||||
|
{
|
||||||
|
if (value[index] != Padding)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed class EphemeralCursorProtector : IDisposable
|
||||||
|
{
|
||||||
|
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public string Protect(string prefix, ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
string content = $"{prefix}.{EncodeBytes(payload)}";
|
||||||
|
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string cursor = $"{content}.{EncodeBytes(signature)}";
|
||||||
|
return ContractValidation.IsCursorValid(cursor)
|
||||||
|
? cursor
|
||||||
|
: throw new InvalidOperationException("The protected cursor exceeds its contract limit.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryUnprotect(string prefix, string? cursor, out byte[] payload)
|
||||||
|
{
|
||||||
|
payload = [];
|
||||||
|
if (_disposed || !ContractValidation.IsCursorValid(cursor))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] segments = cursor!.Split('.');
|
||||||
|
if (segments.Length != 3 || !string.Equals(segments[0], prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] expected = HMACSHA256.HashData(
|
||||||
|
_key,
|
||||||
|
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
|
||||||
|
if (!TryDecodeBytes(segments[2], out byte[] supplied))
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validSignature = supplied.Length == expected.Length
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
|
||||||
|
CryptographicOperations.ZeroMemory(supplied);
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
return validSignature && TryDecodeBytes(segments[1], out payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
CryptographicOperations.ZeroMemory(_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[EphemeralCursorProtector: key redacted]";
|
||||||
|
|
||||||
|
private static string EncodeBytes(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
|
||||||
|
private static bool TryDecodeBytes(string value, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
if (string.IsNullOrEmpty(value)
|
||||||
|
|| value.Any(static character =>
|
||||||
|
character is not (>= 'A' and <= 'Z')
|
||||||
|
and not (>= 'a' and <= 'z')
|
||||||
|
and not (>= '0' and <= '9')
|
||||||
|
and not '-'
|
||||||
|
and not '_'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string padded = value.Replace('-', '+').Replace('_', '/');
|
||||||
|
padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(padded);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed class SessionBrowserCursorCodec : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "rvc1";
|
||||||
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
|
|
||||||
|
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
BrowserCursorPayload payload = new()
|
||||||
|
{
|
||||||
|
GameId = query.Scope.GameId.Value,
|
||||||
|
EnvironmentId = query.Scope.EnvironmentId.Value,
|
||||||
|
ProtocolVersion = query.ProtocolVersion,
|
||||||
|
RegionId = query.RegionId?.Value,
|
||||||
|
ExcludeFull = query.ExcludeFull,
|
||||||
|
AfterListingId = after.ToString(),
|
||||||
|
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _protector.Protect(Prefix, encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDecode(
|
||||||
|
string? cursor,
|
||||||
|
TenantScope scope,
|
||||||
|
uint protocolVersion,
|
||||||
|
RegionId? regionId,
|
||||||
|
bool excludeFull,
|
||||||
|
DateTimeOffset now,
|
||||||
|
out SessionListingId? after)
|
||||||
|
{
|
||||||
|
after = null;
|
||||||
|
if (cursor is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserCursorPayload? payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.Deserialize<BrowserCursorPayload>(
|
||||||
|
encodedPayload,
|
||||||
|
ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is null
|
||||||
|
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||||
|
|| !string.Equals(payload.GameId, scope.GameId.Value, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ProtocolVersion != protocolVersion
|
||||||
|
|| !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ExcludeFull != excludeFull
|
||||||
|
|| !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
after = listingId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _protector.Dispose();
|
||||||
|
|
||||||
|
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class BrowserCursorPayload
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string GameId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string EnvironmentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public uint ProtocolVersion { get; set; }
|
||||||
|
|
||||||
|
public string? RegionId { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool ExcludeFull { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string AfterListingId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionBrowserService(
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
SessionBrowserCursorCodec cursors,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = Validate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
TenantScope scope = new(request.GameId, request.EnvironmentId);
|
||||||
|
if (!cursors.TryDecode(
|
||||||
|
request.Cursor,
|
||||||
|
scope,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.RegionId,
|
||||||
|
request.ExcludeFull,
|
||||||
|
clock.UtcNow,
|
||||||
|
out SessionListingId? after))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
VisibleListingQuery query = new(
|
||||||
|
scope,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.RegionId,
|
||||||
|
request.PageSize + 1,
|
||||||
|
after,
|
||||||
|
request.ExcludeFull);
|
||||||
|
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
|
||||||
|
query,
|
||||||
|
cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||||
|
? RendezvousErrorCode.ServiceUnavailable
|
||||||
|
: RendezvousErrorCode.InternalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SessionListing> items = found.Value
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.Select(ToContract)
|
||||||
|
.ToList();
|
||||||
|
bool hasMore = found.Value.Count > request.PageSize;
|
||||||
|
while (items.Count > 0)
|
||||||
|
{
|
||||||
|
string? nextCursor = hasMore
|
||||||
|
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
||||||
|
: null;
|
||||||
|
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
|
||||||
|
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
||||||
|
<= ContractLimits.BrowserResponseMaxBytes)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.None, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.RemoveAt(items.Count - 1);
|
||||||
|
hasMore = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public BrowserServiceResult<GetSessionResponse> Get(
|
||||||
|
SessionListingId listingId,
|
||||||
|
GameId gameId,
|
||||||
|
EnvironmentId environmentId,
|
||||||
|
uint protocolVersion,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (listingId.Value == Guid.Empty
|
||||||
|
|| string.IsNullOrEmpty(gameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(environmentId.Value)
|
||||||
|
|| protocolVersion == 0)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> found = store.GetListing(listingId, true, cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||||
|
? RendezvousErrorCode.ServiceUnavailable
|
||||||
|
: RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing listing = found.Value;
|
||||||
|
if (listing.Definition.Scope != new TenantScope(gameId, environmentId)
|
||||||
|
|| listing.Definition.ProtocolVersion != protocolVersion)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new GetSessionResponse
|
||||||
|
{
|
||||||
|
Session = ToContract(listing),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
|| (request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value))
|
||||||
|
|| !ContractValidation.IsPageSizeValid(request.PageSize)
|
||||||
|
|| !ContractValidation.IsCursorValid(request.Cursor)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SessionListing ToContract(StoredListing stored) => new()
|
||||||
|
{
|
||||||
|
ListingId = stored.Definition.ListingId,
|
||||||
|
GameId = stored.Definition.Scope.GameId,
|
||||||
|
EnvironmentId = stored.Definition.Scope.EnvironmentId,
|
||||||
|
RegionId = stored.Definition.RegionId,
|
||||||
|
ProtocolVersion = stored.Definition.ProtocolVersion,
|
||||||
|
BuildVersion = stored.Definition.BuildVersion,
|
||||||
|
DisplayName = stored.Definition.DisplayName,
|
||||||
|
Visibility = stored.Definition.Visibility,
|
||||||
|
PublisherTrustMode = stored.Definition.TrustMode,
|
||||||
|
Capacity = new()
|
||||||
|
{
|
||||||
|
CurrentPlayers = stored.Definition.CurrentPlayers,
|
||||||
|
MaximumPlayers = stored.Definition.MaximumPlayers,
|
||||||
|
},
|
||||||
|
Metadata = stored.Definition.Metadata.ToDictionary(
|
||||||
|
static item => item.Key,
|
||||||
|
static item => item.Value,
|
||||||
|
StringComparer.Ordinal),
|
||||||
|
DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||||
|
|
||||||
|
internal sealed record ConnectionOutcomeServiceResult(
|
||||||
|
RendezvousErrorCode Error,
|
||||||
|
ReportConnectionOutcomeResponse? Value = null)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ConnectionOutcomeMetrics
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<(ConnectionOutcomeKind, ConnectionElapsedBucket), long> _counts = [];
|
||||||
|
|
||||||
|
internal void Record(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
(ConnectionOutcomeKind, ConnectionElapsedBucket) key = (outcome, elapsedBucket);
|
||||||
|
_counts.TryGetValue(key, out long count);
|
||||||
|
_counts[key] = count + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal long GetCount(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _counts.GetValueOrDefault((outcome, elapsedBucket));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ConnectionOutcomeService(
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
ConnectionOutcomeMetrics metrics)
|
||||||
|
{
|
||||||
|
internal ConnectionOutcomeServiceResult Report(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string? clientPunchCapability,
|
||||||
|
ReportConnectionOutcomeRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(
|
||||||
|
request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(clientPunchCapability)
|
||||||
|
|| !TryNormalizeReport(request, out ConnectionOutcomeKind outcome, out ConnectionElapsedBucket elapsedBucket)
|
||||||
|
|| !capabilities.TryFingerprint(
|
||||||
|
clientPunchCapability,
|
||||||
|
out SecretFingerprint capabilityFingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredConnectionOutcome> reported = store.ReportConnectionOutcome(new(
|
||||||
|
attemptId,
|
||||||
|
capabilityFingerprint,
|
||||||
|
outcome,
|
||||||
|
elapsedBucket), cancellationToken);
|
||||||
|
if (!reported.Succeeded)
|
||||||
|
{
|
||||||
|
return new(reported.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reported.IsIdempotentReplay)
|
||||||
|
{
|
||||||
|
metrics.Record(outcome, elapsedBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new ReportConnectionOutcomeResponse
|
||||||
|
{
|
||||||
|
Accepted = true,
|
||||||
|
IsDuplicate = reported.IsIdempotentReplay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNormalizeReport(
|
||||||
|
ReportConnectionOutcomeRequest request,
|
||||||
|
out ConnectionOutcomeKind outcome,
|
||||||
|
out ConnectionElapsedBucket elapsedBucket)
|
||||||
|
{
|
||||||
|
outcome = request.Outcome switch
|
||||||
|
{
|
||||||
|
ConnectionOutcomeKind.TimedOut => ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
ConnectionOutcomeKind.StaleHost => ConnectionOutcomeKind.NoHostPresence,
|
||||||
|
ConnectionOutcomeKind.TransportFailed => ConnectionOutcomeKind.TransportError,
|
||||||
|
_ => request.Outcome,
|
||||||
|
};
|
||||||
|
if (!ContractValidation.IsReportableConnectionOutcome(request.Outcome))
|
||||||
|
{
|
||||||
|
elapsedBucket = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Enum.IsDefined(request.ElapsedBucket))
|
||||||
|
{
|
||||||
|
elapsedBucket = request.ElapsedBucket;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma warning disable CS0618 // Frozen v1 compatibility input; never retained at exact precision.
|
||||||
|
if (request.ElapsedBucket == default && request.ElapsedMilliseconds >= 0)
|
||||||
|
{
|
||||||
|
elapsedBucket = BucketElapsedMilliseconds(request.ElapsedMilliseconds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
|
elapsedBucket = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConnectionElapsedBucket BucketElapsedMilliseconds(int elapsedMilliseconds) =>
|
||||||
|
elapsedMilliseconds switch
|
||||||
|
{
|
||||||
|
< 1_000 => ConnectionElapsedBucket.UnderOneSecond,
|
||||||
|
< 5_000 => ConnectionElapsedBucket.OneToFiveSeconds,
|
||||||
|
< 15_000 => ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||||
|
< 30_000 => ConnectionElapsedBucket.FifteenToThirtySeconds,
|
||||||
|
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
|
using System.Net;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Http;
|
namespace FinalFactory.Rendezvous.Server.Http;
|
||||||
|
|
||||||
internal static class ContractEndpoints
|
internal static class ContractEndpoints
|
||||||
{
|
{
|
||||||
private const int NotImplementedStatus = StatusCodes.Status501NotImplemented;
|
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
|
public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
|
||||||
this IEndpointRouteBuilder endpoints)
|
this IEndpointRouteBuilder endpoints)
|
||||||
{
|
{
|
||||||
@@ -14,34 +19,58 @@ internal static class ContractEndpoints
|
|||||||
sessions.MapPost("/", RegisterSession)
|
sessions.MapPost("/", RegisterSession)
|
||||||
.Accepts<RegisterSessionRequest>("application/json")
|
.Accepts<RegisterSessionRequest>("application/json")
|
||||||
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
|
.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");
|
.WithName("RegisterSession");
|
||||||
sessions.MapPost("/{listingId}/renew", RenewLease)
|
sessions.MapPost("/{listingId}/renew", RenewLease)
|
||||||
.Accepts<RenewLeaseRequest>("application/json")
|
.Accepts<RenewLeaseRequest>("application/json")
|
||||||
.Produces<RenewLeaseResponse>()
|
.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");
|
.WithName("RenewSessionLease");
|
||||||
sessions.MapPut("/{listingId}", UpdateSession)
|
sessions.MapPut("/{listingId}", UpdateSession)
|
||||||
.Accepts<UpdateSessionRequest>("application/json")
|
.Accepts<UpdateSessionRequest>("application/json")
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.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");
|
.WithName("UpdateSession");
|
||||||
sessions.MapDelete("/{listingId}", DeleteSession)
|
sessions.MapDelete("/{listingId}", DeleteSession)
|
||||||
.Accepts<DeleteSessionRequest>("application/json")
|
.Accepts<DeleteSessionRequest>("application/json")
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("DeleteSession");
|
.WithName("DeleteSession");
|
||||||
sessions.MapGet("/", BrowseSessions)
|
sessions.MapGet("/", BrowseSessions)
|
||||||
.Produces<BrowseSessionsResponse>()
|
.Produces<BrowseSessionsResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("BrowseSessions");
|
.WithName("BrowseSessions");
|
||||||
sessions.MapGet("/{listingId}", GetSession)
|
sessions.MapGet("/{listingId}", GetSession)
|
||||||
.Produces<GetSessionResponse>()
|
.Produces<GetSessionResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("GetSession");
|
.WithName("GetSession");
|
||||||
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
||||||
.Produces<BrowseHostJoinAttemptsResponse>()
|
.Produces<BrowseHostJoinAttemptsResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("BrowseHostJoinAttempts");
|
.WithName("BrowseHostJoinAttempts");
|
||||||
|
|
||||||
RouteGroupBuilder attempts = endpoints
|
RouteGroupBuilder attempts = endpoints
|
||||||
@@ -50,31 +79,140 @@ internal static class ContractEndpoints
|
|||||||
attempts.MapPost("/", CreateJoinAttempt)
|
attempts.MapPost("/", CreateJoinAttempt)
|
||||||
.Accepts<CreateJoinAttemptRequest>("application/json")
|
.Accepts<CreateJoinAttemptRequest>("application/json")
|
||||||
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("CreateJoinAttempt");
|
.WithName("CreateJoinAttempt");
|
||||||
|
attempts.MapDelete("/{attemptId}", CancelJoinAttempt)
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("CancelJoinAttempt");
|
||||||
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
||||||
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
||||||
.Produces<ReportConnectionOutcomeResponse>()
|
.Produces<ReportConnectionOutcomeResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("ReportConnectionOutcome");
|
.WithName("ReportConnectionOutcome");
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult RegisterSession([FromBody] RegisterSessionRequest request) =>
|
private static IResult RegisterSession(
|
||||||
NotImplemented();
|
[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(
|
private static IResult RenewLease(
|
||||||
SessionListingId listingId,
|
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(
|
private static IResult UpdateSession(
|
||||||
SessionListingId listingId,
|
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(
|
private static IResult DeleteSession(
|
||||||
SessionListingId listingId,
|
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(
|
private static IResult BrowseSessions(
|
||||||
[FromQuery] int contractVersion,
|
[FromQuery] int contractVersion,
|
||||||
@@ -83,30 +221,205 @@ internal static class ContractEndpoints
|
|||||||
[FromQuery] uint protocolVersion,
|
[FromQuery] uint protocolVersion,
|
||||||
[FromQuery] string? regionId,
|
[FromQuery] string? regionId,
|
||||||
[FromQuery] int? pageSize,
|
[FromQuery] int? pageSize,
|
||||||
[FromQuery] string? cursor) => NotImplemented();
|
[FromQuery] bool? excludeFull,
|
||||||
|
[FromQuery] string? cursor,
|
||||||
|
[FromServices] SessionBrowserService browser,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||||
|
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|
||||||
|
|| (regionId is not null && !RegionId.TryParse(regionId, out _)))
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult GetSession(SessionListingId listingId) => NotImplemented();
|
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
|
||||||
|
{
|
||||||
|
ContractVersion = contractVersion,
|
||||||
|
GameId = parsedGameId,
|
||||||
|
EnvironmentId = parsedEnvironmentId,
|
||||||
|
ProtocolVersion = protocolVersion,
|
||||||
|
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||||
|
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||||
|
ExcludeFull = excludeFull ?? false,
|
||||||
|
Cursor = cursor,
|
||||||
|
}, cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult GetSession(
|
||||||
|
SessionListingId listingId,
|
||||||
|
[FromQuery] int contractVersion,
|
||||||
|
[FromQuery] string gameId,
|
||||||
|
[FromQuery] string environmentId,
|
||||||
|
[FromQuery] uint protocolVersion,
|
||||||
|
[FromServices] SessionBrowserService browser,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.UnsupportedContractVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||||
|
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId))
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserServiceResult<GetSessionResponse> result = browser.Get(
|
||||||
|
listingId,
|
||||||
|
parsedGameId,
|
||||||
|
parsedEnvironmentId,
|
||||||
|
protocolVersion,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult BrowseHostJoinAttempts(
|
private static IResult BrowseHostJoinAttempts(
|
||||||
SessionListingId listingId,
|
SessionListingId listingId,
|
||||||
[FromQuery] int contractVersion,
|
[FromQuery] int contractVersion,
|
||||||
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
|
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
|
||||||
[FromQuery] int? pageSize,
|
[FromQuery] int? pageSize,
|
||||||
[FromQuery] string? cursor) => NotImplemented();
|
[FromQuery] string? cursor,
|
||||||
|
[FromServices] JoinAttemptService attempts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
|
||||||
|
listingId,
|
||||||
|
contractVersion,
|
||||||
|
leaseToken,
|
||||||
|
pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||||
|
cursor,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult CreateJoinAttempt([FromBody] CreateJoinAttemptRequest request) =>
|
private static IResult CreateJoinAttempt(
|
||||||
NotImplemented();
|
[FromBody] CreateJoinAttemptRequest request,
|
||||||
|
[FromServices] JoinAttemptService attempts,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress)
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
|
||||||
|
clientSubject,
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult CancelJoinAttempt(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||||
|
[FromServices] JoinAttemptService attempts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
JoinAttemptServiceResult<bool> result = attempts.Cancel(
|
||||||
|
attemptId,
|
||||||
|
clientPunchCapability,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult ReportConnectionOutcome(
|
private static IResult ReportConnectionOutcome(
|
||||||
JoinAttemptId attemptId,
|
JoinAttemptId attemptId,
|
||||||
[FromBody] ReportConnectionOutcomeRequest request) => NotImplemented();
|
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||||
|
[FromBody] ReportConnectionOutcomeRequest request,
|
||||||
|
[FromServices] ConnectionOutcomeService outcomes,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ConnectionOutcomeServiceResult result = outcomes.Report(
|
||||||
|
attemptId,
|
||||||
|
clientPunchCapability,
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult NotImplemented() => Results.Json(
|
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
|
new ApiError
|
||||||
{
|
{
|
||||||
Code = RendezvousErrorCode.ServiceUnavailable,
|
Code = code,
|
||||||
Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.",
|
Message = ErrorMessage(code),
|
||||||
},
|
},
|
||||||
ContractJson.Options,
|
ContractJson.Options,
|
||||||
statusCode: NotImplementedStatus);
|
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 => StatusCodes.Status404NotFound,
|
||||||
|
RendezvousErrorCode.Conflict
|
||||||
|
or RendezvousErrorCode.IncompatibleProtocol
|
||||||
|
or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
|
||||||
|
RendezvousErrorCode.Expired or RendezvousErrorCode.StaleHost => 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.StaleHost => "The session has no fresh host presence.",
|
||||||
|
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
|
||||||
|
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
|
||||||
|
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
|
||||||
|
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
|
||||||
|
_ => "The session request is invalid.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Http;
|
||||||
|
|
||||||
|
internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
||||||
|
{
|
||||||
|
public async ValueTask<bool> TryHandleAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (httpContext.Response.HasStarted)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool invalidRequest = exception is BadHttpRequestException or JsonException;
|
||||||
|
httpContext.Response.StatusCode = invalidRequest
|
||||||
|
? StatusCodes.Status400BadRequest
|
||||||
|
: StatusCodes.Status500InternalServerError;
|
||||||
|
await httpContext.Response.WriteAsJsonAsync(
|
||||||
|
new ApiError
|
||||||
|
{
|
||||||
|
Code = invalidRequest
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.InternalError,
|
||||||
|
Message = invalidRequest
|
||||||
|
? "The request body, route, or query value is invalid."
|
||||||
|
: "The service could not complete the request.",
|
||||||
|
},
|
||||||
|
ContractJson.Options,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptCursorCodec : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "rvj1";
|
||||||
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
|
|
||||||
|
public string Encode(
|
||||||
|
SessionListingId listingId,
|
||||||
|
JoinAttemptId after,
|
||||||
|
DateTimeOffset now)
|
||||||
|
{
|
||||||
|
JoinAttemptCursorPayload payload = new()
|
||||||
|
{
|
||||||
|
ListingId = listingId.ToString(),
|
||||||
|
AfterAttemptId = after.ToString(),
|
||||||
|
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _protector.Protect(Prefix, encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDecode(
|
||||||
|
string? cursor,
|
||||||
|
SessionListingId listingId,
|
||||||
|
DateTimeOffset now,
|
||||||
|
out JoinAttemptId? after)
|
||||||
|
{
|
||||||
|
after = null;
|
||||||
|
if (cursor is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinAttemptCursorPayload? payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.Deserialize<JoinAttemptCursorPayload>(
|
||||||
|
encodedPayload,
|
||||||
|
ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is null
|
||||||
|
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||||
|
|| !string.Equals(payload.ListingId, listingId.ToString(), StringComparison.Ordinal)
|
||||||
|
|| !JoinAttemptId.TryParse(payload.AfterAttemptId, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
after = attemptId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _protector.Dispose();
|
||||||
|
|
||||||
|
public override string ToString() => "[JoinAttemptCursorCodec: key and cursors redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptCursorPayload
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string ListingId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string AfterAttemptId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed record JoinAttemptServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ConnectionTicketGrant(string Ticket, DateTimeOffset ExpiresAt)
|
||||||
|
{
|
||||||
|
public override string ToString() => "[ConnectionTicketGrant: ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptService(
|
||||||
|
GamePolicyRegistry policies,
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
JoinAttemptCursorCodec cursors,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public string CreateAnonymousClientSubject(IPAddress remoteAddress)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(remoteAddress);
|
||||||
|
IPAddress normalized = remoteAddress.IsIPv4MappedToIPv6
|
||||||
|
? remoteAddress.MapToIPv4()
|
||||||
|
: remoteAddress;
|
||||||
|
return capabilities.DeriveOpaqueIdentifier("join-http-client", normalized.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<CreateJoinAttemptResponse> Create(
|
||||||
|
string clientSubject,
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
if (string.IsNullOrWhiteSpace(clientSubject))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A bounded client subject is required.", nameof(clientSubject));
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode validation = ValidateCreate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policies.TryGet(request.GameId, request.EnvironmentId, out GamePolicy? policy)
|
||||||
|
|| policy is null)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.AllowsProtocol(request.ProtocolVersion))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.IncompatibleProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
string requestFingerprint = ComputeRequestFingerprint(request);
|
||||||
|
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||||
|
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
|
||||||
|
"join-attempt-id",
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
string connectionTicket = NatIntroductionTokenCodec.Encode(
|
||||||
|
attemptId,
|
||||||
|
Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt));
|
||||||
|
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|
||||||
|
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(connectionTicket, out SecretFingerprint ticketFingerprint))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
|
||||||
|
}
|
||||||
|
|
||||||
|
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
|
||||||
|
"join-mediation-handle",
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
StoreResult<StoredJoinAttempt> created = store.CreateJoinAttempt(new()
|
||||||
|
{
|
||||||
|
IdempotencyOwner = clientSubject,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
RequestFingerprint = requestFingerprint,
|
||||||
|
ClientSubject = clientSubject,
|
||||||
|
AttemptId = attemptId,
|
||||||
|
MediationHandle = mediationHandle,
|
||||||
|
Scope = new(request.GameId, request.EnvironmentId),
|
||||||
|
ListingId = request.ListingId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
HostCapabilityFingerprint = hostFingerprint,
|
||||||
|
ClientCapabilityFingerprint = clientFingerprint,
|
||||||
|
ConnectionTicketFingerprint = ticketFingerprint,
|
||||||
|
CapabilityDerivationSalt = derivationSalt,
|
||||||
|
ScopeAttemptLimit = policy.MaxActiveJoinAttempts,
|
||||||
|
}, cancellationToken);
|
||||||
|
if (!created.Succeeded || created.Value is null)
|
||||||
|
{
|
||||||
|
return new(created.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredJoinAttempt persisted = created.Value;
|
||||||
|
clientCapability = Derive(
|
||||||
|
"join-client-punch",
|
||||||
|
persisted.ClientSubject,
|
||||||
|
persisted.IdempotencyKey,
|
||||||
|
persisted.RequestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
if (!capabilities.TryFingerprint(clientCapability, out SecretFingerprint persistedFingerprint)
|
||||||
|
|| persistedFingerprint != persisted.ClientCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its client capability.");
|
||||||
|
}
|
||||||
|
return new(RendezvousErrorCode.None, new CreateJoinAttemptResponse
|
||||||
|
{
|
||||||
|
AttemptId = persisted.AttemptId,
|
||||||
|
MediationHandle = persisted.MediationHandle,
|
||||||
|
ClientPunchCapability = clientCapability,
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
CreateConnectionTicket(persisted)),
|
||||||
|
ExpiresAt = persisted.ExpiresAt,
|
||||||
|
DedicatedFallback = StoredListing.CopyEndpoint(persisted.DedicatedFallback),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> BrowseForHost(
|
||||||
|
SessionListingId listingId,
|
||||||
|
int contractVersion,
|
||||||
|
string? leaseToken,
|
||||||
|
int pageSize,
|
||||||
|
string? cursor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||||
|
|| !ContractValidation.IsPageSizeValid(pageSize)
|
||||||
|
|| !ContractValidation.IsCursorValid(cursor)
|
||||||
|
|| !capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cursors.TryDecode(cursor, listingId, clock.UtcNow, out JoinAttemptId? after))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<IReadOnlyList<StoredJoinAttempt>> found = store.BrowseHostJoinAttempts(new(
|
||||||
|
listingId,
|
||||||
|
leaseFingerprint,
|
||||||
|
pageSize + 1,
|
||||||
|
after), cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasMore = found.Value.Count > pageSize;
|
||||||
|
StoredJoinAttempt[] page = found.Value.Take(pageSize).ToArray();
|
||||||
|
BrowseHostJoinAttemptsResponse response = new()
|
||||||
|
{
|
||||||
|
Items = page.Select(CreateHostAttempt).ToList(),
|
||||||
|
NextCursor = hasMore && page.Length > 0
|
||||||
|
? cursors.Encode(listingId, page[^1].AttemptId, clock.UtcNow)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||||
|
return ContractValidation.IsBrowserResponseSizeValid(encodedBytes)
|
||||||
|
? new(RendezvousErrorCode.None, response)
|
||||||
|
: new(RendezvousErrorCode.CapacityExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<bool> Cancel(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string? clientPunchCapability,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsCapabilityValid(clientPunchCapability)
|
||||||
|
|| !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<bool> cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken);
|
||||||
|
return cancelled.Succeeded
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(cancelled.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<ConnectionTicketGrant> IssueConnectionTicket(
|
||||||
|
StoredJoinAttempt attempt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(attempt);
|
||||||
|
if (!attempt.IntroductionConsumed || attempt.IsCancelled)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
string ticket = CreateConnectionTicket(attempt);
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(ticket)
|
||||||
|
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|
||||||
|
|| fingerprint != attempt.ConnectionTicketFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its connection ticket.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new(ticket, attempt.ConnectionTicketExpiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private HostJoinAttempt CreateHostAttempt(StoredJoinAttempt attempt)
|
||||||
|
{
|
||||||
|
string capability = Derive(
|
||||||
|
"join-host-punch",
|
||||||
|
attempt.ClientSubject,
|
||||||
|
attempt.IdempotencyKey,
|
||||||
|
attempt.RequestFingerprint,
|
||||||
|
attempt.CapabilityDerivationSalt);
|
||||||
|
if (!ContractValidation.IsCapabilityValid(capability)
|
||||||
|
|| !capabilities.TryFingerprint(capability, out SecretFingerprint fingerprint)
|
||||||
|
|| fingerprint != attempt.HostCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its host capability.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
AttemptId = attempt.AttemptId,
|
||||||
|
MediationHandle = attempt.MediationHandle,
|
||||||
|
HostPunchCapability = capability,
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
CreateConnectionTicket(attempt)),
|
||||||
|
IsCancelled = attempt.IsCancelled,
|
||||||
|
ExpiresAt = attempt.ExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CreateConnectionTicket(StoredJoinAttempt attempt) =>
|
||||||
|
NatIntroductionTokenCodec.Encode(
|
||||||
|
attempt.AttemptId,
|
||||||
|
Derive(
|
||||||
|
"connection-ticket",
|
||||||
|
attempt.ClientSubject,
|
||||||
|
attempt.IdempotencyKey,
|
||||||
|
attempt.RequestFingerprint,
|
||||||
|
attempt.CapabilityDerivationSalt));
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||||
|
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| request.ListingId.Value == Guid.Empty
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeRequestFingerprint(CreateJoinAttemptRequest request)
|
||||||
|
{
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(request, ContractJson.Options);
|
||||||
|
byte[] digest = SHA256.HashData(encoded);
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Derive(
|
||||||
|
string purpose,
|
||||||
|
string clientSubject,
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt) => Derive(
|
||||||
|
purpose,
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
|
||||||
|
private string Derive(
|
||||||
|
string purpose,
|
||||||
|
string clientSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt) => capabilities.DeriveCapability(
|
||||||
|
purpose,
|
||||||
|
clientSubject,
|
||||||
|
idempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
|
||||||
|
private static bool CredentialLengthsAreValid(
|
||||||
|
string hostCapability,
|
||||||
|
string clientCapability,
|
||||||
|
string ticket) =>
|
||||||
|
ContractValidation.IsCapabilityValid(hostCapability)
|
||||||
|
&& ContractValidation.IsCapabilityValid(clientCapability)
|
||||||
|
&& ContractValidation.IsConnectionTicketValid(ticket)
|
||||||
|
&& hostCapability.Length == ContractLimits.DerivedCredentialCharacters
|
||||||
|
&& clientCapability.Length == ContractLimits.DerivedCredentialCharacters
|
||||||
|
&& ticket.Length == ContractLimits.DerivedCredentialCharacters
|
||||||
|
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
|
||||||
|
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
|
||||||
|
|
||||||
|
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||||
using FinalFactory.Rendezvous.Server.Http;
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
using FinalFactory.Rendezvous.Server.Transport;
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
|
|
||||||
@@ -12,6 +17,7 @@ bool isOpenApiGeneration = string.Equals(
|
|||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
builder.Services.AddOpenApi("v1", static options =>
|
builder.Services.AddOpenApi("v1", static options =>
|
||||||
|
{
|
||||||
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
|
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
|
||||||
{
|
{
|
||||||
Type type = context.JsonTypeInfo.Type;
|
Type type = context.JsonTypeInfo.Type;
|
||||||
@@ -31,9 +37,85 @@ builder.Services.AddOpenApi("v1", static options =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
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.",
|
||||||
|
};
|
||||||
|
const string attemptSchemeName = "JoinAttemptCapability";
|
||||||
|
document.Components.SecuritySchemes[attemptSchemeName] = new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = SecuritySchemeType.ApiKey,
|
||||||
|
Name = "X-Rendezvous-Client-Punch-Capability",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Description = "Attempt-scoped client capability returned only to the joining caller.",
|
||||||
|
};
|
||||||
|
|
||||||
|
HashSet<string> securedOperations = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"RegisterSession",
|
||||||
|
"RenewSessionLease",
|
||||||
|
"UpdateSession",
|
||||||
|
"DeleteSession",
|
||||||
|
};
|
||||||
|
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
|
||||||
|
OpenApiSecuritySchemeReference attemptReference = new(attemptSchemeName, 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] = [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
||||||
|
operation => operation.OperationId is
|
||||||
|
"CancelJoinAttempt" or "ReportConnectionOutcome"))
|
||||||
|
{
|
||||||
|
operation.Security ??= [];
|
||||||
|
operation.Security.Add(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
[attemptReference] = [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
});
|
||||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
ContractJson.Configure(options.SerializerOptions));
|
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)
|
if (isOpenApiGeneration)
|
||||||
{
|
{
|
||||||
@@ -55,6 +137,17 @@ else
|
|||||||
builder.Services.AddSingleton(provisioning.Policies);
|
builder.Services.AddSingleton(provisioning.Policies);
|
||||||
builder.Services.AddSingleton(provisioning.Credentials);
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
EphemeralCapabilityIssuer sessionCapabilities = new();
|
||||||
|
builder.Services.AddSingleton(sessionCapabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(sessionCapabilities);
|
||||||
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
|
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
||||||
|
builder.Services.AddSingleton<ConnectionOutcomeService>();
|
||||||
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,18 +156,27 @@ builder.Services
|
|||||||
.BindConfiguration(UdpMediatorOptions.SectionName)
|
.BindConfiguration(UdpMediatorOptions.SectionName)
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.Validate(
|
.Validate(
|
||||||
options => IPAddress.TryParse(options.ListenAddress, out _),
|
options => IPAddress.TryParse(options.ListenAddress, out IPAddress? address)
|
||||||
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
|
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork,
|
||||||
|
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IPv4 address.")
|
||||||
|
.Validate(
|
||||||
|
options => string.IsNullOrWhiteSpace(options.Ipv6ListenAddress)
|
||||||
|
|| (IPAddress.TryParse(options.Ipv6ListenAddress, out IPAddress? address)
|
||||||
|
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6),
|
||||||
|
$"{UdpMediatorOptions.SectionName}:Ipv6ListenAddress must be an IPv6 address when configured.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
builder.Services.AddSingleton<UdpMediatorService>();
|
builder.Services.AddSingleton<UdpMediatorService>();
|
||||||
if (!isOpenApiGeneration)
|
if (!isOpenApiGeneration)
|
||||||
{
|
{
|
||||||
|
builder.Services.AddSingleton<NatMediationProcessor>();
|
||||||
builder.Services.AddHostedService(static services =>
|
builder.Services.AddHostedService(static services =>
|
||||||
services.GetRequiredService<UdpMediatorService>());
|
services.GetRequiredService<UdpMediatorService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
||||||
|
|
||||||
|
app.UseExceptionHandler();
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
app.MapRendezvousContractEndpoints();
|
app.MapRendezvousContractEndpoints();
|
||||||
app.MapGet(
|
app.MapGet(
|
||||||
@@ -85,8 +187,14 @@ app.MapGet(
|
|||||||
.WithTags("Health");
|
.WithTags("Health");
|
||||||
app.MapGet(
|
app.MapGet(
|
||||||
"/health/ready",
|
"/health/ready",
|
||||||
static (UdpMediatorService mediator, ProvisioningReadiness provisioning) =>
|
static (
|
||||||
mediator.LocalEndpoint is null || !provisioning.IsReady
|
UdpMediatorService mediator,
|
||||||
|
ProvisioningReadiness provisioning,
|
||||||
|
IEphemeralRendezvousStore state) =>
|
||||||
|
mediator.LocalEndpoint is null
|
||||||
|
|| !provisioning.IsReady
|
||||||
|
|| !state.IsAvailable
|
||||||
|
|| state.IsDraining
|
||||||
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
|
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
|
||||||
: Results.Ok(new HealthResponse { Status = "ready" }))
|
: Results.Ok(new HealthResponse { Status = "ready" }))
|
||||||
.Produces<HealthResponse>()
|
.Produces<HealthResponse>()
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ internal sealed class PrincipalCredentialService
|
|||||||
|
|
||||||
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
|
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
|
||||||
{
|
{
|
||||||
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
|
return CredentialValidationResult.Invalid(CredentialValidationError.SignatureInvalid);
|
||||||
}
|
}
|
||||||
|
|
||||||
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
|
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
|
||||||
@@ -441,8 +441,15 @@ internal static class Base64Url
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
bytes = Convert.FromBase64String(padded);
|
bytes = Convert.FromBase64String(padded);
|
||||||
|
if (string.Equals(Encode(bytes), value, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
bytes = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
|
||||||
|
internal interface ISessionCapabilityService
|
||||||
|
{
|
||||||
|
string CreateDerivationSalt();
|
||||||
|
string DeriveCapability(
|
||||||
|
string purpose,
|
||||||
|
string ownerSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt);
|
||||||
|
Guid DeriveGuid(
|
||||||
|
string purpose,
|
||||||
|
string ownerSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt);
|
||||||
|
string DeriveOpaqueIdentifier(string purpose, string value);
|
||||||
|
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public string CreateDerivationSalt()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
byte[] salt = RandomNumberGenerator.GetBytes(32);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(salt);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(salt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DeriveCapability(
|
||||||
|
string purpose,
|
||||||
|
string ownerSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt)
|
||||||
|
{
|
||||||
|
byte[] digest = Derive(
|
||||||
|
purpose,
|
||||||
|
ownerSubject,
|
||||||
|
idempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid DeriveGuid(
|
||||||
|
string purpose,
|
||||||
|
string ownerSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt)
|
||||||
|
{
|
||||||
|
byte[] digest = Derive(
|
||||||
|
purpose,
|
||||||
|
ownerSubject,
|
||||||
|
idempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Span<byte> guidBytes = digest.AsSpan(0, 16);
|
||||||
|
guidBytes[7] = (byte)((guidBytes[7] & 0x0f) | 0x80);
|
||||||
|
guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80);
|
||||||
|
return new Guid(guidBytes);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DeriveOpaqueIdentifier(string purpose, string value)
|
||||||
|
{
|
||||||
|
byte[] digest = Derive(purpose, value);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
|
||||||
|
{
|
||||||
|
fingerprint = default;
|
||||||
|
if (_disposed
|
||||||
|
|| capability is null
|
||||||
|
|| capability.Length != 43
|
||||||
|
|| capability.Any(static character =>
|
||||||
|
character is not (>= 'A' and <= 'Z')
|
||||||
|
and not (>= 'a' and <= 'z')
|
||||||
|
and not (>= '0' and <= '9')
|
||||||
|
and not '-'
|
||||||
|
and not '_'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] digest = Derive("fingerprint", capability);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fingerprint = new SecretFingerprint(Encode(digest));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
CryptographicOperations.ZeroMemory(_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[EphemeralCapabilityIssuer: key and capabilities redacted]";
|
||||||
|
|
||||||
|
private byte[] Derive(params string[] segments)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
using IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, _key);
|
||||||
|
Span<byte> length = stackalloc byte[sizeof(int)];
|
||||||
|
foreach (string segment in segments)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(segment);
|
||||||
|
byte[] encoded = Encoding.UTF8.GetBytes(segment);
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(length, encoded.Length);
|
||||||
|
hmac.AppendData(length);
|
||||||
|
hmac.AppendData(encoded);
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hmac.GetHashAndReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
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;
|
||||||
|
if (!IsFallbackAllowed(context.Policy, request.DedicatedFallback))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
DedicatedFallback = request.DedicatedFallback,
|
||||||
|
LeaseFingerprint = leaseFingerprint,
|
||||||
|
HostPresenceHandle = presenceHandle,
|
||||||
|
HostPresenceFingerprint = presenceFingerprint,
|
||||||
|
CapabilityDerivationSalt = derivationSalt,
|
||||||
|
},
|
||||||
|
ownerLimit), cancellationToken);
|
||||||
|
if (!created.Succeeded || created.Value is null)
|
||||||
|
{
|
||||||
|
return new(created.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
ListingDefinition persisted = created.Value.Definition;
|
||||||
|
leaseToken = capabilities.DeriveCapability(
|
||||||
|
"lease-token",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
presenceCapability = capabilities.DeriveCapability(
|
||||||
|
"host-presence",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new RegisterSessionResponse
|
||||||
|
{
|
||||||
|
ListingId = persisted.ListingId,
|
||||||
|
LeaseId = persisted.LeaseId,
|
||||||
|
LeaseToken = leaseToken,
|
||||||
|
HostPresenceHandle = persisted.HostPresenceHandle,
|
||||||
|
HostPresenceCapability = presenceCapability,
|
||||||
|
ExpiresAt = created.Value.LeaseExpiresAt,
|
||||||
|
LeaseRenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||||
|
HostPresenceRefreshAfterSeconds = timing.HostPresenceRefreshAfterSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionServiceResult<RenewLeaseResponse> Renew(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
RenewLeaseRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||||
|
principal,
|
||||||
|
listingId,
|
||||||
|
request.LeaseToken,
|
||||||
|
cancellationToken,
|
||||||
|
out StoredListing? listing);
|
||||||
|
if (lookup != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing ownedListing = listing!;
|
||||||
|
PublisherAuthorizationResult authorized = AuthorizeExisting(
|
||||||
|
principal,
|
||||||
|
ownedListing,
|
||||||
|
ownedListing.Definition.Metadata);
|
||||||
|
if (!authorized.IsAllowed)
|
||||||
|
{
|
||||||
|
return new(MapAuthorization(authorized.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||||
|
StoreResult<StoredListing> renewed = store.RenewLease(new(
|
||||||
|
listingId,
|
||||||
|
ownedListing.Definition.LeaseId,
|
||||||
|
fingerprint,
|
||||||
|
ownedListing.Definition.OwnerSubject,
|
||||||
|
ownedListing.Version), cancellationToken);
|
||||||
|
return renewed.Succeeded && renewed.Value is not null
|
||||||
|
? new(RendezvousErrorCode.None, new RenewLeaseResponse
|
||||||
|
{
|
||||||
|
ExpiresAt = renewed.Value.LeaseExpiresAt,
|
||||||
|
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||||
|
})
|
||||||
|
: new(renewed.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionServiceResult<bool> Update(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
UpdateSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateUpdate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||||
|
principal,
|
||||||
|
listingId,
|
||||||
|
request.LeaseToken,
|
||||||
|
cancellationToken,
|
||||||
|
out StoredListing? listing);
|
||||||
|
if (lookup != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing ownedListing = listing!;
|
||||||
|
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
|
||||||
|
if (!authorized.IsAllowed || authorized.Context is null)
|
||||||
|
{
|
||||||
|
return new(MapAuthorization(authorized.Error));
|
||||||
|
}
|
||||||
|
if (!IsFallbackAllowed(authorized.Context.Policy, request.DedicatedFallback))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
request.DedicatedFallback), cancellationToken);
|
||||||
|
return updated.Succeeded
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(updated.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionServiceResult<bool> Delete(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
DeleteSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (principal is not IPublisherPrincipal publisher
|
||||||
|
|| !capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return found.Code == StoreResultCode.ServiceUnavailable
|
||||||
|
? new(RendezvousErrorCode.ServiceUnavailable)
|
||||||
|
: new(RendezvousErrorCode.None, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<bool> deleted = store.DeleteListing(new(
|
||||||
|
listingId,
|
||||||
|
found.Value.Definition.LeaseId,
|
||||||
|
fingerprint,
|
||||||
|
publisher.Subject), cancellationToken);
|
||||||
|
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(deleted.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
private RendezvousErrorCode GetAuthorizedListing(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
string leaseToken,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
out StoredListing? listing)
|
||||||
|
{
|
||||||
|
listing = null;
|
||||||
|
if (principal is not IPublisherPrincipal publisher)
|
||||||
|
{
|
||||||
|
return RendezvousErrorCode.Forbidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return RendezvousErrorCode.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return found.Code.ToContractError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|
||||||
|
|| found.Value.Definition.LeaseFingerprint != fingerprint)
|
||||||
|
{
|
||||||
|
return RendezvousErrorCode.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
listing = found.Value;
|
||||||
|
return RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublisherAuthorizationResult AuthorizeExisting(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
StoredListing listing,
|
||||||
|
IReadOnlyDictionary<string, string> metadata) => authorization.Authorize(
|
||||||
|
principal,
|
||||||
|
listing.Definition.Scope.GameId,
|
||||||
|
listing.Definition.Scope.EnvironmentId,
|
||||||
|
listing.Definition.RegionId,
|
||||||
|
listing.Definition.ProtocolVersion,
|
||||||
|
listing.Definition.Visibility,
|
||||||
|
metadata,
|
||||||
|
clock.UtcNow);
|
||||||
|
|
||||||
|
private static bool IsFallbackAllowed(GamePolicy policy, NetworkEndpoint? fallback) =>
|
||||||
|
fallback is null || policy.FallbackPolicy == FallbackPolicyMode.DedicatedEndpointAllowed;
|
||||||
|
|
||||||
|
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)
|
||||||
|
|| request.DedicatedFallback is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
||||||
|
? 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)
|
||||||
|
|| request.DedicatedFallback is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateLeaseRequest(int contractVersion, string leaseToken)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||||
|
? RendezvousErrorCode.None
|
||||||
|
: RendezvousErrorCode.InvalidRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode MapAuthorization(PublisherAuthorizationError error) => error switch
|
||||||
|
{
|
||||||
|
PublisherAuthorizationError.PrincipalExpired => RendezvousErrorCode.AuthenticationRequired,
|
||||||
|
PublisherAuthorizationError.ProtocolNotAllowed => RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
PublisherAuthorizationError.RegionNotAllowed
|
||||||
|
or PublisherAuthorizationError.VisibilityNotAllowed
|
||||||
|
or PublisherAuthorizationError.AnonymousMustBeUnlisted
|
||||||
|
or PublisherAuthorizationError.MetadataNotAllowed => RendezvousErrorCode.InvalidRequest,
|
||||||
|
_ => RendezvousErrorCode.Forbidden,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
|
||||||
|
{
|
||||||
|
RegisterSessionRequest canonical = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Visibility = request.Visibility,
|
||||||
|
Capacity = new SessionCapacity
|
||||||
|
{
|
||||||
|
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||||
|
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||||
|
},
|
||||||
|
Metadata = request.Metadata
|
||||||
|
.OrderBy(static item => item.Key, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
|
||||||
|
DedicatedFallback = request.DedicatedFallback is null
|
||||||
|
? null
|
||||||
|
: new NetworkEndpoint
|
||||||
|
{
|
||||||
|
AddressFamily = request.DedicatedFallback.AddressFamily,
|
||||||
|
Address = request.DedicatedFallback.Address,
|
||||||
|
Port = request.DedicatedFallback.Port,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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,398 @@
|
|||||||
|
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 MaxOutcomeReports { get; init; } = 35_000;
|
||||||
|
public int MaxReplayEntries { get; init; } = 30_000;
|
||||||
|
public int MaxRevocations { get; init; } = 10_000;
|
||||||
|
public int MaxIdempotencyEntries { get; init; } = 35_000;
|
||||||
|
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
|
||||||
|
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||||
|
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
public TimeSpan ConnectionTicketLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||||
|
public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
|
||||||
|
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
RequirePositive(MaxListings, nameof(MaxListings));
|
||||||
|
RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings));
|
||||||
|
RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts));
|
||||||
|
RequirePositive(MaxOutcomeReports, nameof(MaxOutcomeReports));
|
||||||
|
RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries));
|
||||||
|
RequirePositive(MaxRevocations, nameof(MaxRevocations));
|
||||||
|
RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries));
|
||||||
|
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
|
||||||
|
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
|
||||||
|
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
|
||||||
|
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
|
||||||
|
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
||||||
|
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
||||||
|
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
||||||
|
if (ConnectionTicketLifetime > JoinAttemptLifetime)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(ConnectionTicketLifetime),
|
||||||
|
"Connection tickets cannot outlive their join attempt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(IdempotencyLifetime),
|
||||||
|
"Idempotency retention must cover every idempotent resource lifetime.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequirePositive(int value, string name)
|
||||||
|
{
|
||||||
|
if (value <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(name, "Store capacity must be positive.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireDuration(TimeSpan value, TimeSpan maximum, string name)
|
||||||
|
{
|
||||||
|
if (value <= TimeSpan.Zero || value > maximum)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(name, $"Duration must be positive and no greater than {maximum}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct TenantScope(GameId GameId, EnvironmentId EnvironmentId);
|
||||||
|
|
||||||
|
internal readonly struct SecretFingerprint : IEquatable<SecretFingerprint>
|
||||||
|
{
|
||||||
|
private readonly string? _value;
|
||||||
|
|
||||||
|
public SecretFingerprint(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Secret fingerprints must contain 1-128 characters.", nameof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
_value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsValid => !string.IsNullOrWhiteSpace(_value) && _value.Length <= 128;
|
||||||
|
public bool Equals(SecretFingerprint other)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<char> left = _value.AsSpan();
|
||||||
|
ReadOnlySpan<char> right = other._value.AsSpan();
|
||||||
|
if (left.Length != right.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int difference = 0;
|
||||||
|
for (int index = 0; index < left.Length; index++)
|
||||||
|
{
|
||||||
|
difference |= left[index] ^ right[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return difference == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Equals(object? obj) => obj is SecretFingerprint other && Equals(other);
|
||||||
|
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(_value ?? string.Empty);
|
||||||
|
public override string ToString() => "[REDACTED]";
|
||||||
|
public static bool operator ==(SecretFingerprint left, SecretFingerprint right) => left.Equals(right);
|
||||||
|
public static bool operator !=(SecretFingerprint left, SecretFingerprint right) => !left.Equals(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct ObservedEndpoint
|
||||||
|
{
|
||||||
|
public ObservedEndpoint(AddressFamilyKind addressFamily, string address, int port)
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(address, out IPAddress? parsed)
|
||||||
|
|| (addressFamily == AddressFamilyKind.Ipv4 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
|
|| (addressFamily == AddressFamilyKind.Ipv6 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The address must match the declared address family.", nameof(address));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(port));
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressFamily = addressFamily;
|
||||||
|
Address = parsed.ToString();
|
||||||
|
Port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AddressFamilyKind AddressFamily { get; }
|
||||||
|
public string Address { get; }
|
||||||
|
public int Port { get; }
|
||||||
|
public bool IsValid => !string.IsNullOrEmpty(Address)
|
||||||
|
&& Port is >= 1 and <= 65_535
|
||||||
|
&& AddressFamily is AddressFamilyKind.Ipv4 or AddressFamilyKind.Ipv6;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ListingDefinition
|
||||||
|
{
|
||||||
|
public required SessionListingId ListingId { get; init; }
|
||||||
|
public required LeaseId LeaseId { get; init; }
|
||||||
|
public required TenantScope Scope { get; init; }
|
||||||
|
public required string OwnerSubject { get; init; }
|
||||||
|
public required RegionId RegionId { get; init; }
|
||||||
|
public required uint ProtocolVersion { get; init; }
|
||||||
|
public required string BuildVersion { get; init; }
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
public required ListingVisibility Visibility { get; init; }
|
||||||
|
public required PublisherTrustMode TrustMode { get; init; }
|
||||||
|
public required int CurrentPlayers { get; init; }
|
||||||
|
public required int MaximumPlayers { get; init; }
|
||||||
|
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
|
||||||
|
public NetworkEndpoint? DedicatedFallback { 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),
|
||||||
|
DedicatedFallback = CopyEndpoint(source.DedicatedFallback),
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static NetworkEndpoint? CopyEndpoint(NetworkEndpoint? endpoint) => endpoint is null
|
||||||
|
? null
|
||||||
|
: new NetworkEndpoint
|
||||||
|
{
|
||||||
|
AddressFamily = endpoint.AddressFamily,
|
||||||
|
Address = endpoint.Address,
|
||||||
|
Port = endpoint.Port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
NetworkEndpoint? DedicatedFallback);
|
||||||
|
|
||||||
|
internal sealed record DeleteListingCommand(
|
||||||
|
SessionListingId ListingId,
|
||||||
|
LeaseId LeaseId,
|
||||||
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
string OwnerSubject);
|
||||||
|
|
||||||
|
internal sealed record BindHostPresenceCommand(
|
||||||
|
MediationHandle Handle,
|
||||||
|
SecretFingerprint CapabilityFingerprint,
|
||||||
|
ObservedEndpoint PublicEndpoint,
|
||||||
|
ObservedEndpoint? LocalEndpoint);
|
||||||
|
|
||||||
|
internal sealed record VisibleListingQuery(
|
||||||
|
TenantScope Scope,
|
||||||
|
uint ProtocolVersion,
|
||||||
|
RegionId? RegionId,
|
||||||
|
int MaximumResults = ContractLimits.BrowserPageMaxItems,
|
||||||
|
SessionListingId? AfterListingId = null,
|
||||||
|
bool ExcludeFull = false);
|
||||||
|
|
||||||
|
internal enum AttemptPeerRole
|
||||||
|
{
|
||||||
|
Host = 1,
|
||||||
|
Client = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record CreateJoinAttemptCommand
|
||||||
|
{
|
||||||
|
public required string IdempotencyOwner { get; init; }
|
||||||
|
public required string IdempotencyKey { get; init; }
|
||||||
|
public required string RequestFingerprint { get; init; }
|
||||||
|
public required string ClientSubject { get; init; }
|
||||||
|
public required JoinAttemptId AttemptId { get; init; }
|
||||||
|
public required MediationHandle MediationHandle { get; init; }
|
||||||
|
public required TenantScope Scope { get; init; }
|
||||||
|
public required SessionListingId ListingId { get; init; }
|
||||||
|
public required uint ProtocolVersion { get; init; }
|
||||||
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; init; }
|
||||||
|
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
||||||
|
|
||||||
|
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record AttemptEndpointBinding(
|
||||||
|
ObservedEndpoint PublicEndpoint,
|
||||||
|
ObservedEndpoint? LocalEndpoint);
|
||||||
|
|
||||||
|
internal sealed record StoredJoinAttempt
|
||||||
|
{
|
||||||
|
public required JoinAttemptId AttemptId { get; init; }
|
||||||
|
public required MediationHandle MediationHandle { get; init; }
|
||||||
|
public required TenantScope Scope { get; init; }
|
||||||
|
public required SessionListingId ListingId { get; init; }
|
||||||
|
public required string ClientSubject { get; init; }
|
||||||
|
public required uint ProtocolVersion { get; init; }
|
||||||
|
public required string IdempotencyKey { get; init; }
|
||||||
|
public required string RequestFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; init; }
|
||||||
|
public required DateTimeOffset ExpiresAt { get; init; }
|
||||||
|
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
||||||
|
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
||||||
|
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||||
|
public required bool IntroductionConsumed { get; init; }
|
||||||
|
public required bool ConnectionTicketConsumed { get; init; }
|
||||||
|
public required bool IsCancelled { get; init; }
|
||||||
|
|
||||||
|
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record HostJoinAttemptQuery(
|
||||||
|
SessionListingId ListingId,
|
||||||
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
int MaximumResults,
|
||||||
|
JoinAttemptId? AfterAttemptId = null);
|
||||||
|
|
||||||
|
internal sealed record BindAttemptEndpointCommand(
|
||||||
|
MediationHandle Handle,
|
||||||
|
AttemptPeerRole Role,
|
||||||
|
SecretFingerprint CapabilityFingerprint,
|
||||||
|
ObservedEndpoint PublicEndpoint,
|
||||||
|
ObservedEndpoint? LocalEndpoint);
|
||||||
|
|
||||||
|
internal sealed record IntroductionEndpoints(
|
||||||
|
StoredJoinAttempt Attempt,
|
||||||
|
AttemptEndpointBinding Host,
|
||||||
|
AttemptEndpointBinding Client)
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId => Attempt.AttemptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record CancelJoinAttemptCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ClientCapabilityFingerprint);
|
||||||
|
|
||||||
|
internal sealed record ReportConnectionOutcomeCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ClientCapabilityFingerprint,
|
||||||
|
ConnectionOutcomeKind Outcome,
|
||||||
|
ConnectionElapsedBucket ElapsedBucket);
|
||||||
|
|
||||||
|
internal sealed record StoredConnectionOutcome(
|
||||||
|
ConnectionOutcomeKind Outcome,
|
||||||
|
ConnectionElapsedBucket ElapsedBucket);
|
||||||
|
|
||||||
|
internal sealed record ConsumeConnectionTicketCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ConnectionTicketFingerprint);
|
||||||
|
|
||||||
|
internal sealed record ReplayConsumption(
|
||||||
|
string Namespace,
|
||||||
|
string Key,
|
||||||
|
TimeSpan? Lifetime = null);
|
||||||
|
|
||||||
|
internal enum StoreResultCode
|
||||||
|
{
|
||||||
|
Success = 0,
|
||||||
|
NotFound = 1,
|
||||||
|
Expired = 2,
|
||||||
|
Revoked = 3,
|
||||||
|
Conflict = 4,
|
||||||
|
CapacityExceeded = 5,
|
||||||
|
Draining = 6,
|
||||||
|
ReplayRejected = 7,
|
||||||
|
ServiceUnavailable = 8,
|
||||||
|
StaleHost = 9,
|
||||||
|
IncompatibleProtocol = 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record StoreResult<T>(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Code == StoreResultCode.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IEphemeralRendezvousStore
|
||||||
|
{
|
||||||
|
Guid InstanceId { get; }
|
||||||
|
bool IsAvailable { get; }
|
||||||
|
bool IsDraining { get; }
|
||||||
|
|
||||||
|
StoreResult<StoredListing> CreateListing(CreateListingCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredListing> RenewLease(RenewLeaseCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredListing> UpdateListing(UpdateListingCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> DeleteListing(DeleteListingCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredListing> GetListing(SessionListingId listingId, bool requireFreshPresence, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredConnectionOutcome> ReportConnectionOutcome(ReportConnectionOutcomeCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
|
||||||
|
void BeginDrain(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
internal static class StoreResultMapping
|
||||||
|
{
|
||||||
|
public static RendezvousErrorCode ToContractError(this StoreResultCode code) => code switch
|
||||||
|
{
|
||||||
|
StoreResultCode.Success => RendezvousErrorCode.None,
|
||||||
|
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
|
||||||
|
StoreResultCode.Expired => RendezvousErrorCode.Expired,
|
||||||
|
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
|
||||||
|
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
|
||||||
|
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
|
||||||
|
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
|
||||||
|
StoreResultCode.StaleHost => RendezvousErrorCode.StaleHost,
|
||||||
|
StoreResultCode.IncompatibleProtocol => RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
_ => RendezvousErrorCode.InternalError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
|
internal static class LiteNetNatRequestCodec
|
||||||
|
{
|
||||||
|
private const byte NatMessageProperty = 17;
|
||||||
|
private const int TypeIdentifierLength = 8;
|
||||||
|
private const int TokenLengthPrefix = NatPunchRequestTokenCodec.EncodedLength + 1;
|
||||||
|
// LiteNetLib 2.1.4's private NatIntroduceRequest type ID. The native socket
|
||||||
|
// integration test deliberately fails if a package upgrade changes this wire value.
|
||||||
|
private static ReadOnlySpan<byte> RequestTypeIdentifier =>
|
||||||
|
[0x88, 0xbe, 0x10, 0x26, 0xbf, 0xb1, 0x66, 0x9c];
|
||||||
|
|
||||||
|
public static bool TryDecode(
|
||||||
|
ReadOnlySpan<byte> datagram,
|
||||||
|
out IPEndPoint? claimedLocalEndpoint,
|
||||||
|
out string? token)
|
||||||
|
{
|
||||||
|
claimedLocalEndpoint = null;
|
||||||
|
token = null;
|
||||||
|
if (datagram.Length < 1 + TypeIdentifierLength + 1 + 4 + 2 + 2
|
||||||
|
+ NatPunchRequestTokenCodec.EncodedLength
|
||||||
|
|| datagram[0] != NatMessageProperty
|
||||||
|
|| !datagram.Slice(1, TypeIdentifierLength).SequenceEqual(RequestTypeIdentifier))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int offset = 1 + TypeIdentifierLength;
|
||||||
|
int addressLength = datagram[offset++] switch
|
||||||
|
{
|
||||||
|
0 => 4,
|
||||||
|
1 => 16,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
int expectedLength = offset + addressLength + 2 + 2
|
||||||
|
+ NatPunchRequestTokenCodec.EncodedLength;
|
||||||
|
if (addressLength == 0 || datagram.Length != expectedLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress localAddress = new(datagram.Slice(offset, addressLength));
|
||||||
|
offset += addressLength;
|
||||||
|
int localPort = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
|
||||||
|
offset += 2;
|
||||||
|
int encodedTokenLength = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
|
||||||
|
offset += 2;
|
||||||
|
if (localPort == 0 || encodedTokenLength != TokenLengthPrefix)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> tokenBytes = datagram.Slice(
|
||||||
|
offset,
|
||||||
|
NatPunchRequestTokenCodec.EncodedLength);
|
||||||
|
for (int index = 0; index < tokenBytes.Length; index++)
|
||||||
|
{
|
||||||
|
if (tokenBytes[index] > 0x7f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedLocalEndpoint = new(localAddress, localPort);
|
||||||
|
token = Encoding.ASCII.GetString(tokenBytes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
|
internal interface INatIntroductionSink
|
||||||
|
{
|
||||||
|
void Introduce(NatIntroductionPlan plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record NatIntroductionPlan(
|
||||||
|
IPEndPoint HostLocal,
|
||||||
|
IPEndPoint HostPublic,
|
||||||
|
IPEndPoint ClientLocal,
|
||||||
|
IPEndPoint ClientPublic,
|
||||||
|
string IntroductionToken)
|
||||||
|
{
|
||||||
|
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum NatMediationResult
|
||||||
|
{
|
||||||
|
Dropped = 0,
|
||||||
|
HostPresenceAccepted = 1,
|
||||||
|
HostPresenceRejected = 2,
|
||||||
|
WaitingForPeer = 3,
|
||||||
|
Introduced = 4,
|
||||||
|
Duplicate = 5,
|
||||||
|
Rejected = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class NatMediationProcessor(
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
JoinAttemptService joinAttempts)
|
||||||
|
{
|
||||||
|
public NatMediationResult ProcessDatagram(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||||
|
|| datagram is null
|
||||||
|
|| datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters
|
||||||
|
|| !IPAddress.TryParse(datagram.LocalAddress, out IPAddress? localAddress))
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPEndPoint claimedLocalEndpoint = new(localAddress, datagram.LocalPort);
|
||||||
|
NatPunchPeerRole role = datagram.MessageType == UdpPresenceMessageType.ClientPresence
|
||||||
|
? NatPunchPeerRole.Client
|
||||||
|
: NatPunchPeerRole.HostPresence;
|
||||||
|
bool observedIpv6 = observedPublicEndpoint.AddressFamily == AddressFamily.InterNetworkV6
|
||||||
|
&& !observedPublicEndpoint.Address.IsIPv4MappedToIPv6;
|
||||||
|
if (role == NatPunchPeerRole.Client && observedIpv6)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
NatMediationResult result = ProcessRequest(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
if (role != NatPunchPeerRole.HostPresence
|
||||||
|
|| result != NatMediationResult.HostPresenceRejected
|
||||||
|
|| observedIpv6)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProcessRequest(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
datagram.MediationHandle,
|
||||||
|
datagram.Capability),
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NatMediationResult ProcessRequest(
|
||||||
|
IPEndPoint claimedLocalEndpoint,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
string token,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(claimedLocalEndpoint);
|
||||||
|
ArgumentNullException.ThrowIfNull(observedPublicEndpoint);
|
||||||
|
ArgumentNullException.ThrowIfNull(introductionSink);
|
||||||
|
|
||||||
|
if (!NatPunchRequestTokenCodec.TryDecode(token, out NatPunchRequestToken? request)
|
||||||
|
|| request is null
|
||||||
|
|| !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint)
|
||||||
|
|| !capabilities.TryFingerprint(request.Capability, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
ObservedEndpoint? localEndpoint = TryCreatePrivateCandidate(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
publicEndpoint.AddressFamily,
|
||||||
|
out ObservedEndpoint candidate)
|
||||||
|
? candidate
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (request.Role == NatPunchPeerRole.HostPresence)
|
||||||
|
{
|
||||||
|
StoreResult<StoredListing> presence = store.BindHostPresence(new(
|
||||||
|
request.MediationHandle,
|
||||||
|
fingerprint,
|
||||||
|
publicEndpoint,
|
||||||
|
localEndpoint), cancellationToken);
|
||||||
|
return presence.Succeeded
|
||||||
|
? NatMediationResult.HostPresenceAccepted
|
||||||
|
: NatMediationResult.HostPresenceRejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
AttemptPeerRole role = request.Role switch
|
||||||
|
{
|
||||||
|
NatPunchPeerRole.Host => AttemptPeerRole.Host,
|
||||||
|
NatPunchPeerRole.Client => AttemptPeerRole.Client,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
if (role == default)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredJoinAttempt> bound = store.BindAttemptEndpoint(new(
|
||||||
|
request.MediationHandle,
|
||||||
|
role,
|
||||||
|
fingerprint,
|
||||||
|
publicEndpoint,
|
||||||
|
localEndpoint), cancellationToken);
|
||||||
|
if (!bound.Succeeded || bound.Value is null)
|
||||||
|
{
|
||||||
|
return bound.Code == StoreResultCode.ReplayRejected
|
||||||
|
? NatMediationResult.Rejected
|
||||||
|
: NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredJoinAttempt attempt = bound.Value;
|
||||||
|
if (attempt.IntroductionConsumed)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Duplicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.HostEndpoint is null || attempt.ClientEndpoint is null)
|
||||||
|
{
|
||||||
|
return NatMediationResult.WaitingForPeer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.HostEndpoint.PublicEndpoint.AddressFamily
|
||||||
|
!= attempt.ClientEndpoint.PublicEndpoint.AddressFamily)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<IntroductionEndpoints> consumed = store.ConsumeIntroduction(
|
||||||
|
request.MediationHandle,
|
||||||
|
cancellationToken);
|
||||||
|
if (!consumed.Succeeded || consumed.Value is null)
|
||||||
|
{
|
||||||
|
return consumed.Code == StoreResultCode.ReplayRejected
|
||||||
|
? NatMediationResult.Duplicate
|
||||||
|
: NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<ConnectionTicketGrant> ticket = joinAttempts.IssueConnectionTicket(
|
||||||
|
consumed.Value.Attempt);
|
||||||
|
if (!ticket.Succeeded || ticket.Value is null)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value));
|
||||||
|
return NatMediationResult.Introduced;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SocketException
|
||||||
|
or InvalidOperationException
|
||||||
|
or ArgumentException)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NatIntroductionPlan CreatePlan(
|
||||||
|
IntroductionEndpoints endpoints,
|
||||||
|
ConnectionTicketGrant ticket)
|
||||||
|
{
|
||||||
|
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
|
||||||
|
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
|
||||||
|
bool sameNat = hostPublic.Address.Equals(clientPublic.Address);
|
||||||
|
IPEndPoint hostLocal = sameNat && endpoints.Host.LocalEndpoint is { } hostCandidate
|
||||||
|
? ToIpEndpoint(hostCandidate)
|
||||||
|
: hostPublic;
|
||||||
|
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
|
||||||
|
? ToIpEndpoint(clientCandidate)
|
||||||
|
: clientPublic;
|
||||||
|
return new(
|
||||||
|
hostLocal,
|
||||||
|
hostPublic,
|
||||||
|
clientLocal,
|
||||||
|
clientPublic,
|
||||||
|
ticket.Ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreateObservedEndpoint(
|
||||||
|
IPEndPoint source,
|
||||||
|
out ObservedEndpoint endpoint)
|
||||||
|
{
|
||||||
|
endpoint = default;
|
||||||
|
if (source.Port is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress address = source.Address.IsIPv4MappedToIPv6
|
||||||
|
? source.Address.MapToIPv4()
|
||||||
|
: source.Address;
|
||||||
|
if (address.Equals(IPAddress.Any)
|
||||||
|
|| address.Equals(IPAddress.IPv6Any)
|
||||||
|
|| address.IsIPv6Multicast
|
||||||
|
|| IsIpv4MulticastOrBroadcast(address)
|
||||||
|
|| (address.AddressFamily == AddressFamily.InterNetworkV6
|
||||||
|
&& !IsGlobalIpv6(address)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressFamilyKind family = address.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||||
|
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
if (family == default)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = new(family, address.ToString(), source.Port);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreatePrivateCandidate(
|
||||||
|
IPEndPoint source,
|
||||||
|
AddressFamilyKind publicFamily,
|
||||||
|
out ObservedEndpoint endpoint)
|
||||||
|
{
|
||||||
|
endpoint = default;
|
||||||
|
if (source.Port is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress address = source.Address.IsIPv4MappedToIPv6
|
||||||
|
? source.Address.MapToIPv4()
|
||||||
|
: source.Address;
|
||||||
|
AddressFamilyKind family = address.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||||
|
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
if (family != publicFamily || !IsPrivateUnicast(address))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = new(family, address.ToString(), source.Port);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPrivateUnicast(IPAddress address)
|
||||||
|
{
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return address.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => bytes[0] == 10
|
||||||
|
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||||
|
|| (bytes[0] == 192 && bytes[1] == 168),
|
||||||
|
AddressFamily.InterNetworkV6 => (bytes[0] & 0xfe) == 0xfc,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGlobalIpv6(IPAddress address) =>
|
||||||
|
!address.Equals(IPAddress.IPv6Loopback)
|
||||||
|
&& !address.Equals(IPAddress.IPv6Any)
|
||||||
|
&& !address.IsIPv6LinkLocal
|
||||||
|
&& !address.IsIPv6Multicast
|
||||||
|
&& !address.IsIPv6SiteLocal
|
||||||
|
&& !IsPrivateUnicast(address)
|
||||||
|
&& !IsDocumentationIpv6(address);
|
||||||
|
|
||||||
|
private static bool IsIpv4MulticastOrBroadcast(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address.AddressFamily != AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return bytes[0] >= 224 || bytes.All(static value => value == byte.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDocumentationIpv6(IPAddress address)
|
||||||
|
{
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPEndPoint ToIpEndpoint(ObservedEndpoint endpoint) =>
|
||||||
|
new(IPAddress.Parse(endpoint.Address), endpoint.Port);
|
||||||
|
}
|
||||||
@@ -18,9 +18,17 @@ public sealed class UdpMediatorOptions
|
|||||||
[Required]
|
[Required]
|
||||||
public string ListenAddress { get; set; } = "0.0.0.0";
|
public string ListenAddress { get; set; } = "0.0.0.0";
|
||||||
|
|
||||||
|
public string? Ipv6ListenAddress { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
|
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Range(0, 65_535)]
|
[Range(0, 65_535)]
|
||||||
public int Port { get; set; } = 9050;
|
public int Port { get; set; } = 9050;
|
||||||
|
|
||||||
|
[Range(1, 4_096)]
|
||||||
|
public int MaxDatagramsPerPoll { get; set; } = 256;
|
||||||
|
|
||||||
|
[Range(1, 100)]
|
||||||
|
public int PollIntervalMilliseconds { get; set; } = 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +1,138 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
using LiteNetLib.Layers;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
/// <summary>
|
internal sealed partial class UdpMediatorService : BackgroundService
|
||||||
/// Owns the cancellable UDP socket used by the future NAT mediator.
|
|
||||||
/// </summary>
|
|
||||||
public sealed partial class UdpMediatorService : BackgroundService
|
|
||||||
{
|
{
|
||||||
private readonly ILogger<UdpMediatorService> _logger;
|
private readonly ILogger<UdpMediatorService> _logger;
|
||||||
private readonly UdpMediatorOptions _options;
|
private readonly UdpMediatorOptions _options;
|
||||||
private UdpClient? _udpClient;
|
private readonly NatMediationProcessor _processor;
|
||||||
|
private LiteNetManager? _manager;
|
||||||
|
private LiteNetIntroductionSink? _introductionSink;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new UDP mediator service.
|
|
||||||
/// </summary>
|
|
||||||
public UdpMediatorService(
|
public UdpMediatorService(
|
||||||
IOptions<UdpMediatorOptions> options,
|
IOptions<UdpMediatorOptions> options,
|
||||||
ILogger<UdpMediatorService> logger)
|
ILogger<UdpMediatorService> logger,
|
||||||
|
NatMediationProcessor processor)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_processor = processor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the bound endpoint after startup completes.
|
|
||||||
/// </summary>
|
|
||||||
public IPEndPoint? LocalEndpoint { get; private set; }
|
public IPEndPoint? LocalEndpoint { get; private set; }
|
||||||
|
public IPEndPoint? LocalIpv6Endpoint { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override Task StartAsync(CancellationToken cancellationToken)
|
public override Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (_manager is not null)
|
||||||
if (_udpClient is not null)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The UDP mediator is already running.");
|
throw new InvalidOperationException("The UDP mediator is already running.");
|
||||||
}
|
}
|
||||||
|
|
||||||
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
|
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
|
||||||
UdpClient udpClient = new(new IPEndPoint(listenAddress, _options.Port));
|
if (listenAddress.AddressFamily != AddressFamily.InterNetwork)
|
||||||
_udpClient = udpClient;
|
{
|
||||||
IPEndPoint localEndpoint =
|
throw new InvalidOperationException("The required UDP listen address must be IPv4.");
|
||||||
(IPEndPoint?)udpClient.Client.LocalEndPoint
|
}
|
||||||
?? throw new InvalidOperationException("The UDP socket did not expose its bound endpoint.");
|
|
||||||
LocalEndpoint = localEndpoint;
|
|
||||||
|
|
||||||
LogMediatorListening(_logger, localEndpoint.Address, localEndpoint.Port);
|
IPAddress? ipv6ListenAddress = string.IsNullOrWhiteSpace(_options.Ipv6ListenAddress)
|
||||||
|
? null
|
||||||
|
: IPAddress.Parse(_options.Ipv6ListenAddress);
|
||||||
|
if (ipv6ListenAddress is not null
|
||||||
|
&& ipv6ListenAddress.AddressFamily != AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The optional UDP IPv6 listen address must be IPv6.");
|
||||||
|
}
|
||||||
|
|
||||||
|
EventBasedLiteNetListener listener = new();
|
||||||
|
RendezvousPacketLayer packetLayer = new(_processor);
|
||||||
|
LiteNetManager manager = new(listener, packetLayer)
|
||||||
|
{
|
||||||
|
NatPunchEnabled = true,
|
||||||
|
IPv6Enabled = ipv6ListenAddress is not null,
|
||||||
|
UnsyncedEvents = true,
|
||||||
|
MaxPacketPerManualReceive = _options.MaxDatagramsPerPoll,
|
||||||
|
};
|
||||||
|
manager.NatPunchModule.UnsyncedEvents = true;
|
||||||
|
_introductionSink = new(manager.NatPunchModule);
|
||||||
|
packetLayer.Attach(_introductionSink);
|
||||||
|
|
||||||
|
if (!manager.StartInManualMode(
|
||||||
|
listenAddress,
|
||||||
|
ipv6ListenAddress ?? IPAddress.IPv6Any,
|
||||||
|
_options.Port))
|
||||||
|
{
|
||||||
|
_introductionSink = null;
|
||||||
|
manager.Stop();
|
||||||
|
throw new InvalidOperationException("The UDP mediator could not bind its LiteNetLib socket.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager = manager;
|
||||||
|
LocalEndpoint = new(listenAddress, manager.LocalPort);
|
||||||
|
LocalIpv6Endpoint = ipv6ListenAddress is null
|
||||||
|
? null
|
||||||
|
: new(ipv6ListenAddress, manager.LocalPort);
|
||||||
|
LogMediatorListening(_logger, listenAddress, manager.LocalPort);
|
||||||
return base.StartAsync(cancellationToken);
|
return base.StartAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_udpClient?.Dispose();
|
StopManager();
|
||||||
_udpClient = null;
|
|
||||||
LocalEndpoint = null;
|
|
||||||
LogMediatorStopped(_logger);
|
LogMediatorStopped(_logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
_udpClient?.Dispose();
|
StopManager();
|
||||||
_udpClient = null;
|
|
||||||
LocalEndpoint = null;
|
|
||||||
base.Dispose();
|
base.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
UdpClient udpClient = _udpClient
|
LiteNetManager manager = _manager
|
||||||
?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
|
?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
|
||||||
|
long previous = Stopwatch.GetTimestamp();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_ = await udpClient.ReceiveAsync(stoppingToken).ConfigureAwait(false);
|
manager.PollEvents();
|
||||||
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
|
manager.NatPunchModule.PollEvents();
|
||||||
|
long current = Stopwatch.GetTimestamp();
|
||||||
|
manager.ManualUpdate((float)Stopwatch.GetElapsedTime(previous, current).TotalMilliseconds);
|
||||||
|
previous = current;
|
||||||
|
await Task.Delay(_options.PollIntervalMilliseconds, stoppingToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Expected during normal shutdown.
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// Disposing the socket is the fallback that releases a blocked receive.
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
LocalEndpoint = null;
|
LocalEndpoint = null;
|
||||||
|
LocalIpv6Endpoint = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void StopManager()
|
||||||
|
{
|
||||||
|
LiteNetManager? manager = Interlocked.Exchange(ref _manager, null);
|
||||||
|
_introductionSink = null;
|
||||||
|
LocalEndpoint = null;
|
||||||
|
LocalIpv6Endpoint = null;
|
||||||
|
manager?.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
[LoggerMessage(
|
[LoggerMessage(
|
||||||
EventId = 1,
|
EventId = 1,
|
||||||
Level = LogLevel.Information,
|
Level = LogLevel.Information,
|
||||||
@@ -113,4 +147,62 @@ public sealed partial class UdpMediatorService : BackgroundService
|
|||||||
Level = LogLevel.Information,
|
Level = LogLevel.Information,
|
||||||
Message = "UDP mediator stopped")]
|
Message = "UDP mediator stopped")]
|
||||||
private static partial void LogMediatorStopped(ILogger logger);
|
private static partial void LogMediatorStopped(ILogger logger);
|
||||||
|
|
||||||
|
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
|
||||||
|
{
|
||||||
|
public void Introduce(NatIntroductionPlan plan) => module.NatIntroduce(
|
||||||
|
plan.HostLocal,
|
||||||
|
plan.HostPublic,
|
||||||
|
plan.ClientLocal,
|
||||||
|
plan.ClientPublic,
|
||||||
|
plan.IntroductionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
|
||||||
|
{
|
||||||
|
private INatIntroductionSink? _sink;
|
||||||
|
|
||||||
|
public void Attach(INatIntroductionSink sink) => _sink = sink;
|
||||||
|
|
||||||
|
public override void ProcessInboundPacket(
|
||||||
|
ref IPEndPoint endPoint,
|
||||||
|
ref byte[] data,
|
||||||
|
ref int length)
|
||||||
|
{
|
||||||
|
bool isFrozenEnvelope = length >= 2
|
||||||
|
&& data[0] == RendezvousUdpCodec.MagicFirst
|
||||||
|
&& data[1] == RendezvousUdpCodec.MagicSecond;
|
||||||
|
INatIntroductionSink? sink = _sink;
|
||||||
|
if (isFrozenEnvelope)
|
||||||
|
{
|
||||||
|
if (sink is not null)
|
||||||
|
{
|
||||||
|
_ = processor.ProcessDatagram(data.AsSpan(0, length), endPoint, sink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (sink is not null
|
||||||
|
&& LiteNetNatRequestCodec.TryDecode(
|
||||||
|
data.AsSpan(0, length),
|
||||||
|
out IPEndPoint? claimedLocalEndpoint,
|
||||||
|
out string? token)
|
||||||
|
&& claimedLocalEndpoint is not null
|
||||||
|
&& token is not null)
|
||||||
|
{
|
||||||
|
_ = processor.ProcessRequest(claimedLocalEndpoint, endPoint, token, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
|
||||||
|
Drop(ref length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void ProcessOutBoundPacket(
|
||||||
|
ref IPEndPoint endPoint,
|
||||||
|
ref byte[] data,
|
||||||
|
ref int offset,
|
||||||
|
ref int length)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Drop(ref int length) => length = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
"Rendezvous": {
|
"Rendezvous": {
|
||||||
"Udp": {
|
"Udp": {
|
||||||
"ListenAddress": "0.0.0.0",
|
"ListenAddress": "0.0.0.0",
|
||||||
"Port": 9050
|
"Port": 9050,
|
||||||
|
"MaxDatagramsPerPoll": 256,
|
||||||
|
"PollIntervalMilliseconds": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using LiteNetLib;
|
||||||
|
using LiteNetLib.Utils;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
internal sealed class DirectEchoProtocol : IDisposable
|
||||||
|
{
|
||||||
|
private const string PingPrefix = "rv1-ping:";
|
||||||
|
private const string EchoPrefix = "rv1-echo:";
|
||||||
|
private const string AckPrefix = "rv1-ack:";
|
||||||
|
private const string DonePrefix = "rv1-done:";
|
||||||
|
private readonly EventBasedNetListener _events;
|
||||||
|
private readonly bool _host;
|
||||||
|
private readonly Dictionary<NetPeer, string> _hostNonces = [];
|
||||||
|
private readonly TaskCompletionSource<bool> _completed = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private string? _nonce;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal DirectEchoProtocol(EventBasedNetListener events, bool host)
|
||||||
|
{
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_host = host;
|
||||||
|
_events.NetworkReceiveEvent += OnReceive;
|
||||||
|
_events.PeerDisconnectedEvent += OnPeerDisconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Task Completion => _completed.Task;
|
||||||
|
internal int PendingHostExchangeCount => _hostNonces.Count;
|
||||||
|
internal event Action<NetPeer>? ExchangeCompleted;
|
||||||
|
|
||||||
|
internal void BeginJoin(NetPeer peer)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (_host || _nonce is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The direct echo exchange is already active.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||||
|
Send(peer, PingPrefix + _nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_events.NetworkReceiveEvent -= OnReceive;
|
||||||
|
_events.PeerDisconnectedEvent -= OnPeerDisconnected;
|
||||||
|
_hostNonces.Clear();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnReceive(
|
||||||
|
NetPeer peer,
|
||||||
|
NetPacketReader reader,
|
||||||
|
byte channel,
|
||||||
|
DeliveryMethod deliveryMethod)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReadOnlySpan<byte> payload = reader.GetRemainingBytesSpan();
|
||||||
|
if (payload.Length is < 9 or > 64)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string message = Encoding.ASCII.GetString(payload);
|
||||||
|
if (_host && TryNonce(message, PingPrefix, out string? pingNonce))
|
||||||
|
{
|
||||||
|
_hostNonces[peer] = pingNonce!;
|
||||||
|
Send(peer, EchoPrefix + pingNonce);
|
||||||
|
}
|
||||||
|
else if (_host
|
||||||
|
&& _hostNonces.TryGetValue(peer, out string? hostNonce)
|
||||||
|
&& string.Equals(message, AckPrefix + hostNonce, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_hostNonces.Remove(peer);
|
||||||
|
Send(peer, DonePrefix + hostNonce);
|
||||||
|
ExchangeCompleted?.Invoke(peer);
|
||||||
|
_completed.TrySetResult(true);
|
||||||
|
}
|
||||||
|
else if (!_host
|
||||||
|
&& _nonce is not null
|
||||||
|
&& string.Equals(message, EchoPrefix + _nonce, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Send(peer, AckPrefix + _nonce);
|
||||||
|
}
|
||||||
|
else if (!_host
|
||||||
|
&& _nonce is not null
|
||||||
|
&& string.Equals(message, DonePrefix + _nonce, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
ExchangeCompleted?.Invoke(peer);
|
||||||
|
_completed.TrySetResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
reader.Recycle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNonce(string message, string prefix, out string? nonce)
|
||||||
|
{
|
||||||
|
nonce = null;
|
||||||
|
if (!message.StartsWith(prefix, StringComparison.Ordinal)
|
||||||
|
|| message.Length != prefix.Length + 32)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
string candidate = message[prefix.Length..];
|
||||||
|
if (!candidate.All(static character => character is >= '0' and <= '9'
|
||||||
|
or >= 'a' and <= 'f'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
nonce = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Send(NetPeer peer, string message) => peer.Send(
|
||||||
|
Encoding.ASCII.GetBytes(message),
|
||||||
|
DeliveryMethod.ReliableOrdered);
|
||||||
|
|
||||||
|
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) =>
|
||||||
|
_hostNonces.Remove(peer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
internal sealed class HostServiceFailureBudget
|
||||||
|
{
|
||||||
|
private const int MaximumConsecutiveTransientFailures = 3;
|
||||||
|
private int _consecutiveTransientFailures;
|
||||||
|
|
||||||
|
internal bool ShouldStop(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
DateTimeOffset leaseExpiresAt,
|
||||||
|
DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (error == RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!IsTransient(error))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_consecutiveTransientFailures++;
|
||||||
|
return _consecutiveTransientFailures >= MaximumConsecutiveTransientFailures
|
||||||
|
|| now >= leaseExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Reset() => _consecutiveTransientFailures = 0;
|
||||||
|
|
||||||
|
private static bool IsTransient(RendezvousErrorCode error) => error is
|
||||||
|
RendezvousErrorCode.RateLimited
|
||||||
|
or RendezvousErrorCode.ServiceUnavailable
|
||||||
|
or RendezvousErrorCode.InternalError;
|
||||||
|
}
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
namespace FinalFactory.Rendezvous.TestClient;
|
namespace FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bootstrap entry point for the public-SDK-only diagnostic client.
|
|
||||||
/// </summary>
|
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
/// <summary>
|
public static async Task<int> Main(string[] args)
|
||||||
/// Runs the bootstrap diagnostic.
|
|
||||||
/// </summary>
|
|
||||||
public static int Main()
|
|
||||||
{
|
{
|
||||||
Console.WriteLine("Rendezvous TestClient bootstrap is ready.");
|
using CancellationTokenSource shutdown = new();
|
||||||
return 0;
|
ConsoleCancelEventHandler cancelHandler = (_, eventArgs) =>
|
||||||
|
{
|
||||||
|
eventArgs.Cancel = true;
|
||||||
|
shutdown.Cancel();
|
||||||
|
};
|
||||||
|
Console.CancelKeyPress += cancelHandler;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TestClientApplication application = new(new RendezvousCommandRunner());
|
||||||
|
return await application.RunAsync(
|
||||||
|
args,
|
||||||
|
Console.In,
|
||||||
|
Console.Out,
|
||||||
|
Console.Error,
|
||||||
|
shutdown.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.CancelKeyPress -= cancelHandler;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# FinalFactory.Rendezvous.TestClient
|
||||||
|
|
||||||
|
This is a diagnostic executable for exercising Rendezvous through the same public
|
||||||
|
Client and Contracts API available to a game. It is not a production game client,
|
||||||
|
server browser, dedicated server, relay, account system, or gameplay host.
|
||||||
|
|
||||||
|
The executable has three explicit modes:
|
||||||
|
|
||||||
|
- `host` publishes a session, maintains presence and its lease, accepts an
|
||||||
|
authenticated direct peer, and answers a bounded ping/echo/ack/completion exchange;
|
||||||
|
- `browse` prints compatible public listings; and
|
||||||
|
- `join` selects or accepts a listing, drives traversal on its caller-owned
|
||||||
|
LiteNetLib socket, proves direct traffic, reports the typed outcome, and exits.
|
||||||
|
|
||||||
|
Run `dotnet run --project src/FinalFactory.Rendezvous.TestClient -- --help` for
|
||||||
|
the complete option reference. A typical script-mode invocation is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<credential from the deployment boundary>'
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||||
|
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
||||||
|
--game space-game --environment development --region local --protocol 1 \
|
||||||
|
--script --json --exit-after-echo
|
||||||
|
```
|
||||||
|
|
||||||
|
Publisher credentials are accepted only through a named environment variable.
|
||||||
|
There is deliberately no command-line credential option because process command
|
||||||
|
lines are routinely exposed to other local tools and diagnostics. Output uses an
|
||||||
|
allowlisted event model and never includes lease tokens, punch capabilities,
|
||||||
|
connection tickets, raw metadata, signing material, or reusable credentials.
|
||||||
|
|
||||||
|
Script mode never prompts. Join mode selects the first compatible listing unless
|
||||||
|
`--listing UUID` fixes the choice. `--json` emits one JSON object per line with
|
||||||
|
`version: 1`; event names and the process exit codes below are stable automation
|
||||||
|
contracts. A script-mode host without `--run-seconds` uses `--timeout-seconds` as
|
||||||
|
its total runtime bound. New optional event properties may be added without changing
|
||||||
|
the version. JSON help and usage failures are versioned events as well; informational
|
||||||
|
events use stdout and failures use stderr.
|
||||||
|
|
||||||
|
| Exit | Meaning |
|
||||||
|
|---:|---|
|
||||||
|
| `0` | Requested diagnostic flow completed successfully |
|
||||||
|
| `2` | Invalid command or options |
|
||||||
|
| `3` | Missing or invalid local configuration |
|
||||||
|
| `10` | HTTP, registration, browser, lease, or socket failure |
|
||||||
|
| `11` | No compatible session was available or selected |
|
||||||
|
| `12` | Authorization or traversal reached a typed terminal failure |
|
||||||
|
| `13` | A requested direct ping/echo proof did not complete |
|
||||||
|
| `130` | Caller cancellation or Ctrl+C |
|
||||||
|
|
||||||
|
The client prints the selected direct endpoint category (`loopback`, `private`, or
|
||||||
|
`public`) but never the raw endpoint. A traversal failure reports whether an
|
||||||
|
authoritative dedicated fallback is available; the diagnostic does not connect to
|
||||||
|
that fallback automatically. A host may publish a policy-authorized endpoint with
|
||||||
|
`--fallback IP:PORT`. See the repository integration guide for process
|
||||||
|
orchestration and topology limitations.
|
||||||
@@ -0,0 +1,774 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan PollDelay = TimeSpan.FromMilliseconds(5);
|
||||||
|
private static readonly TimeSpan HostRefreshInterval = TimeSpan.FromMilliseconds(250);
|
||||||
|
private static readonly TimeSpan DirectTrafficFlushGrace = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
|
public Task<TestClientExitCode> RunAsync(
|
||||||
|
TestClientOptions options,
|
||||||
|
TestClientOutput output,
|
||||||
|
TextReader input,
|
||||||
|
CancellationToken cancellationToken) => options.Mode switch
|
||||||
|
{
|
||||||
|
TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
|
||||||
|
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
|
||||||
|
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
|
||||||
|
_ => Task.FromResult(TestClientExitCode.Usage),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<TestClientExitCode> RunHostAsync(
|
||||||
|
TestClientOptions options,
|
||||||
|
TestClientOutput output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? publisherCredential = Environment.GetEnvironmentVariable(
|
||||||
|
options.PublisherCredentialEnvironmentVariable);
|
||||||
|
if (!ContractValidation.IsOpaqueHttpCredentialValid(publisherCredential))
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"host.configuration",
|
||||||
|
"failed",
|
||||||
|
"The publisher credential environment variable is missing or invalid.",
|
||||||
|
phase: "configuration");
|
||||||
|
return TestClientExitCode.Configuration;
|
||||||
|
}
|
||||||
|
string credential = publisherCredential!;
|
||||||
|
|
||||||
|
using HttpClient http = CreateHttpClient(options);
|
||||||
|
RendezvousPublisherClient publisher = new(http, ClientOptions(options));
|
||||||
|
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
|
||||||
|
RendezvousJoinClient joins = new(http, ClientOptions(options));
|
||||||
|
RendezvousNetListener events = new();
|
||||||
|
NetManager manager = events.CreateManager();
|
||||||
|
if (!manager.Start(options.LocalPort))
|
||||||
|
{
|
||||||
|
output.WriteError("host.socket", "failed", "The gameplay UDP socket could not start.", phase: "presence");
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishedSession? session = null;
|
||||||
|
using CancellationTokenSource hostOperations = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
Task<RendezvousClientResult<int>>? refresh = null;
|
||||||
|
Task<RendezvousClientResult<RenewLeaseResponse>>? renewal = null;
|
||||||
|
Task<RendezvousClientResult<GetSessionResponse>>? readiness = null;
|
||||||
|
DirectEchoProtocol? echo = null;
|
||||||
|
RendezvousHostCoordinator? coordinator = null;
|
||||||
|
TestClientExitCode hostResult = TestClientExitCode.ServiceFailure;
|
||||||
|
bool cleanupFailed = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
output.Write("host.registration", "started", phase: "registration");
|
||||||
|
RendezvousClientResult<PublishedSession> registration;
|
||||||
|
using (CancellationTokenSource registrationTimeout = CreateOperationTimeout(options, cancellationToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
registration = await publisher.RegisterAsync(
|
||||||
|
new RegisterSessionRequest
|
||||||
|
{
|
||||||
|
IdempotencyKey = Guid.NewGuid().ToString("N"),
|
||||||
|
GameId = options.GameId,
|
||||||
|
EnvironmentId = options.EnvironmentId,
|
||||||
|
RegionId = options.RegionId,
|
||||||
|
ProtocolVersion = options.ProtocolVersion,
|
||||||
|
BuildVersion = options.BuildVersion,
|
||||||
|
DisplayName = options.DisplayName,
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new SessionCapacity { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string>(options.Metadata, StringComparer.Ordinal),
|
||||||
|
DedicatedFallback = options.DedicatedFallback,
|
||||||
|
},
|
||||||
|
credential,
|
||||||
|
registrationTimeout.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"host.registration",
|
||||||
|
"timed-out",
|
||||||
|
"Host registration exceeded the bounded startup stage.",
|
||||||
|
phase: "registration");
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!registration.IsSuccess || registration.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "host.registration", "registration", registration);
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
session = registration.Value;
|
||||||
|
output.Write(
|
||||||
|
"host.registered",
|
||||||
|
"registered",
|
||||||
|
phase: "registration",
|
||||||
|
listingId: session.ListingId.ToString(),
|
||||||
|
displayName: options.DisplayName);
|
||||||
|
echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
|
||||||
|
echo.ExchangeCompleted += _ => output.Write(
|
||||||
|
"host.direct-traffic",
|
||||||
|
"verified",
|
||||||
|
phase: "direct-traffic",
|
||||||
|
endpointType: "peer-to-peer");
|
||||||
|
coordinator = new RendezvousHostCoordinator(
|
||||||
|
manager,
|
||||||
|
events,
|
||||||
|
options.Mediator,
|
||||||
|
session,
|
||||||
|
joins,
|
||||||
|
CoordinatorOptions(options));
|
||||||
|
coordinator.AttemptCompleted += (_, completion) =>
|
||||||
|
{
|
||||||
|
output.Write(
|
||||||
|
"host.attempt.completed",
|
||||||
|
completion.Outcome.IsSuccess ? "connected" : "failed",
|
||||||
|
phase: completion.Outcome.Phase.ToString(),
|
||||||
|
outcome: completion.Outcome.Kind.ToString(),
|
||||||
|
elapsedMilliseconds: ToMilliseconds(completion.Outcome.Elapsed));
|
||||||
|
if (completion.Outcome.IsSuccess)
|
||||||
|
{
|
||||||
|
output.Write(
|
||||||
|
"host.direct-connect",
|
||||||
|
"connected",
|
||||||
|
phase: "direct-connection",
|
||||||
|
endpointType: "peer-to-peer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Stopwatch running = Stopwatch.StartNew();
|
||||||
|
TimeSpan nextRefresh = TimeSpan.Zero;
|
||||||
|
TimeSpan nextRenewal = TimeSpan.FromSeconds(session.LeaseRenewAfterSeconds);
|
||||||
|
TimeSpan nextReadinessProbe = TimeSpan.Zero;
|
||||||
|
bool directTrafficReported = false;
|
||||||
|
TimeSpan? directTrafficCompletedAt = null;
|
||||||
|
bool ready = false;
|
||||||
|
bool terminalFailure = false;
|
||||||
|
int previousPendingAttempts = 0;
|
||||||
|
HostServiceFailureBudget refreshFailures = new();
|
||||||
|
HostServiceFailureBudget renewalFailures = new();
|
||||||
|
using PeriodicTimer pollTimer = new(PollDelay);
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
coordinator.Poll();
|
||||||
|
if (coordinator.State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"host.lifecycle",
|
||||||
|
"failed",
|
||||||
|
"The host coordinator stopped before shutdown was requested.",
|
||||||
|
phase: "lifecycle",
|
||||||
|
outcome: coordinator.State.ToString());
|
||||||
|
terminalFailure = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (coordinator.PendingAttemptCount > previousPendingAttempts)
|
||||||
|
{
|
||||||
|
output.Write(
|
||||||
|
"host.punch",
|
||||||
|
"started",
|
||||||
|
phase: "nat-traversal",
|
||||||
|
count: coordinator.PendingAttemptCount);
|
||||||
|
}
|
||||||
|
previousPendingAttempts = coordinator.PendingAttemptCount;
|
||||||
|
if (readiness is { IsCompleted: true })
|
||||||
|
{
|
||||||
|
RendezvousClientResult<GetSessionResponse> result = await readiness.ConfigureAwait(false);
|
||||||
|
readiness = null;
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
ready = true;
|
||||||
|
output.Write(
|
||||||
|
"host.ready",
|
||||||
|
"ready",
|
||||||
|
phase: "presence",
|
||||||
|
listingId: session.ListingId.ToString());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
nextReadinessProbe = running.Elapsed + TimeSpan.FromMilliseconds(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ready && readiness is null && running.Elapsed >= nextReadinessProbe)
|
||||||
|
{
|
||||||
|
readiness = browser.GetAsync(
|
||||||
|
session.ListingId,
|
||||||
|
options.GameId,
|
||||||
|
options.EnvironmentId,
|
||||||
|
options.ProtocolVersion,
|
||||||
|
hostOperations.Token);
|
||||||
|
}
|
||||||
|
if (refresh is { IsCompleted: true })
|
||||||
|
{
|
||||||
|
RendezvousClientResult<int> result = await refresh.ConfigureAwait(false);
|
||||||
|
refresh = null;
|
||||||
|
nextRefresh = running.Elapsed + (result.IsSuccess
|
||||||
|
? HostRefreshInterval
|
||||||
|
: TimeSpan.FromSeconds(1));
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "host.authorization", "authorization", result);
|
||||||
|
if (refreshFailures.ShouldStop(result.Error, session.ExpiresAt, DateTimeOffset.UtcNow))
|
||||||
|
{
|
||||||
|
terminalFailure = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
refreshFailures.Reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (refresh is null && running.Elapsed >= nextRefresh)
|
||||||
|
{
|
||||||
|
refresh = coordinator.RefreshJoinAttemptsAsync(hostOperations.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renewal is { IsCompleted: true })
|
||||||
|
{
|
||||||
|
RendezvousClientResult<RenewLeaseResponse> result = await renewal.ConfigureAwait(false);
|
||||||
|
renewal = null;
|
||||||
|
nextRenewal = running.Elapsed + (result.IsSuccess && result.Value is not null
|
||||||
|
? TimeSpan.FromSeconds(result.Value.RenewAfterSeconds)
|
||||||
|
: TimeSpan.FromSeconds(1));
|
||||||
|
output.Write(
|
||||||
|
"host.lease",
|
||||||
|
result.IsSuccess ? "renewed" : "failed",
|
||||||
|
phase: "lease",
|
||||||
|
message: result.IsSuccess ? null : SafeServiceMessage(result));
|
||||||
|
if (!result.IsSuccess
|
||||||
|
&& renewalFailures.ShouldStop(result.Error, session.ExpiresAt, DateTimeOffset.UtcNow))
|
||||||
|
{
|
||||||
|
terminalFailure = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
renewalFailures.Reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (renewal is null && running.Elapsed >= nextRenewal)
|
||||||
|
{
|
||||||
|
renewal = publisher.RenewAsync(session, credential, hostOperations.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (echo.Completion.IsCompleted && !directTrafficReported)
|
||||||
|
{
|
||||||
|
directTrafficReported = true;
|
||||||
|
directTrafficCompletedAt = running.Elapsed;
|
||||||
|
}
|
||||||
|
if (options.ExitAfterEcho
|
||||||
|
&& directTrafficCompletedAt.HasValue
|
||||||
|
&& running.Elapsed - directTrafficCompletedAt.Value >= DirectTrafficFlushGrace)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (options.RunDuration.HasValue && running.Elapsed >= options.RunDuration.Value)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!await pollTimer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
hostResult = TestClientExitCode.Cancelled;
|
||||||
|
}
|
||||||
|
else if (terminalFailure)
|
||||||
|
{
|
||||||
|
hostResult = TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
else if (options.ExitAfterEcho && !directTrafficReported)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"host.direct-traffic",
|
||||||
|
"timed-out",
|
||||||
|
"No authenticated ping/echo/ack exchange completed within the host runtime.",
|
||||||
|
phase: "direct-traffic");
|
||||||
|
hostResult = TestClientExitCode.DirectTrafficFailed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hostResult = TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
hostOperations.Cancel();
|
||||||
|
await ObserveCancellationAsync(refresh).ConfigureAwait(false);
|
||||||
|
await ObserveCancellationAsync(renewal).ConfigureAwait(false);
|
||||||
|
await ObserveCancellationAsync(readiness).ConfigureAwait(false);
|
||||||
|
coordinator?.Dispose();
|
||||||
|
echo?.Dispose();
|
||||||
|
if (session is not null)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RendezvousClientResult<bool> deregistered = await publisher.DeregisterAsync(
|
||||||
|
session,
|
||||||
|
credential,
|
||||||
|
cleanup.Token).ConfigureAwait(false);
|
||||||
|
output.Write(
|
||||||
|
"host.deregistered",
|
||||||
|
deregistered.IsSuccess ? "complete" : "failed",
|
||||||
|
phase: "lifecycle",
|
||||||
|
listingId: session.ListingId.ToString());
|
||||||
|
if (!deregistered.IsSuccess)
|
||||||
|
{
|
||||||
|
cleanupFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"host.deregistered",
|
||||||
|
"timed-out",
|
||||||
|
"Deregistration did not complete within the cleanup budget.",
|
||||||
|
phase: "lifecycle");
|
||||||
|
cleanupFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
return cleanupFailed && !cancellationToken.IsCancellationRequested
|
||||||
|
? TestClientExitCode.ServiceFailure
|
||||||
|
: hostResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<TestClientExitCode> RunBrowseAsync(
|
||||||
|
TestClientOptions options,
|
||||||
|
TestClientOutput output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource operation = CreateOperationTimeout(options, cancellationToken);
|
||||||
|
using HttpClient http = CreateHttpClient(options);
|
||||||
|
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
|
||||||
|
output.Write("browse.sessions", "started", phase: "directory");
|
||||||
|
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(
|
||||||
|
BrowseRequest(options),
|
||||||
|
maximumPages: 10,
|
||||||
|
cancellationToken: operation.Token).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "browse.sessions", "directory", result);
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteListings(output, result.Value);
|
||||||
|
return result.Value.Count == 0
|
||||||
|
? TestClientExitCode.NoCompatibleSession
|
||||||
|
: TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<TestClientExitCode> RunJoinAsync(
|
||||||
|
TestClientOptions options,
|
||||||
|
TestClientOutput output,
|
||||||
|
TextReader input,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource operation = CreateOperationTimeout(options, cancellationToken);
|
||||||
|
using HttpClient http = CreateHttpClient(options);
|
||||||
|
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
|
||||||
|
RendezvousJoinClient joins = new(http, ClientOptions(options));
|
||||||
|
SessionSelection selection = await SelectListingAsync(
|
||||||
|
options,
|
||||||
|
output,
|
||||||
|
input,
|
||||||
|
browser,
|
||||||
|
operation.Token).ConfigureAwait(false);
|
||||||
|
if (selection.Listing is null)
|
||||||
|
{
|
||||||
|
return selection.ExitCode;
|
||||||
|
}
|
||||||
|
SessionListing listing = selection.Listing;
|
||||||
|
|
||||||
|
RendezvousNetListener events = new();
|
||||||
|
NetManager manager = events.CreateManager();
|
||||||
|
if (!manager.Start(options.LocalPort))
|
||||||
|
{
|
||||||
|
output.WriteError("join.socket", "failed", "The gameplay UDP socket could not start.", phase: "mediation");
|
||||||
|
return TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousClientCoordinator? coordinator = null;
|
||||||
|
bool directConnected = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
output.Write(
|
||||||
|
"join.authorization",
|
||||||
|
"started",
|
||||||
|
phase: "authorization",
|
||||||
|
listingId: listing.ListingId.ToString());
|
||||||
|
RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync(
|
||||||
|
new CreateJoinAttemptRequest
|
||||||
|
{
|
||||||
|
IdempotencyKey = Guid.NewGuid().ToString("N"),
|
||||||
|
GameId = options.GameId,
|
||||||
|
EnvironmentId = options.EnvironmentId,
|
||||||
|
ListingId = listing.ListingId,
|
||||||
|
ProtocolVersion = options.ProtocolVersion,
|
||||||
|
},
|
||||||
|
listing.DedicatedFallback,
|
||||||
|
operation.Token).ConfigureAwait(false);
|
||||||
|
if (start.Outcome is { } serviceOutcome)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
WriteOutcome(output, "join.authorization", serviceOutcome);
|
||||||
|
WriteFallback(output, serviceOutcome);
|
||||||
|
return TestClientExitCode.TraversalFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateJoinAttemptResponse attempt = start.Attempt
|
||||||
|
?? throw new InvalidOperationException("The typed start result had no attempt or outcome.");
|
||||||
|
using DirectEchoProtocol echo = new(events.GameplayEvents, host: false);
|
||||||
|
coordinator = new RendezvousClientCoordinator(
|
||||||
|
manager,
|
||||||
|
events,
|
||||||
|
options.Mediator,
|
||||||
|
attempt,
|
||||||
|
CoordinatorOptions(options));
|
||||||
|
output.Write("join.punch", "started", phase: "nat-traversal");
|
||||||
|
using (CancellationTokenSource traversal = CreateOperationTimeout(options, cancellationToken))
|
||||||
|
using (PeriodicTimer traversalPoll = new(PollDelay))
|
||||||
|
{
|
||||||
|
RendezvousConnectionState previousState = coordinator.State;
|
||||||
|
while (!coordinator.IsCompleted)
|
||||||
|
{
|
||||||
|
traversal.Token.ThrowIfCancellationRequested();
|
||||||
|
coordinator.Poll();
|
||||||
|
if (coordinator.State != previousState)
|
||||||
|
{
|
||||||
|
previousState = coordinator.State;
|
||||||
|
if (previousState == RendezvousConnectionState.Connecting)
|
||||||
|
{
|
||||||
|
output.Write(
|
||||||
|
"join.direct-connect",
|
||||||
|
"started",
|
||||||
|
phase: "direct-connection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!coordinator.IsCompleted
|
||||||
|
&& !await traversalPoll.WaitForNextTickAsync(traversal.Token).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousConnectionOutcome outcome = coordinator.Outcome
|
||||||
|
?? throw new InvalidOperationException("The completed coordinator had no typed outcome.");
|
||||||
|
WriteOutcome(output, "join.traversal", outcome);
|
||||||
|
if (!outcome.IsSuccess || coordinator.ConnectedPeer is null)
|
||||||
|
{
|
||||||
|
WriteFallback(output, outcome);
|
||||||
|
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
|
||||||
|
return TestClientExitCode.TraversalFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
NetPeer peer = coordinator.ConnectedPeer;
|
||||||
|
directConnected = true;
|
||||||
|
string endpointType = EndpointType(peer.Address);
|
||||||
|
output.Write(
|
||||||
|
"join.connected",
|
||||||
|
"connected",
|
||||||
|
phase: "direct-connection",
|
||||||
|
endpointType: endpointType,
|
||||||
|
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
|
||||||
|
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
|
||||||
|
echo.BeginJoin(peer);
|
||||||
|
using (CancellationTokenSource traffic = CreateOperationTimeout(options, cancellationToken))
|
||||||
|
using (PeriodicTimer trafficPoll = new(PollDelay))
|
||||||
|
{
|
||||||
|
while (!echo.Completion.IsCompleted)
|
||||||
|
{
|
||||||
|
traffic.Token.ThrowIfCancellationRequested();
|
||||||
|
manager.PollEvents();
|
||||||
|
if (!echo.Completion.IsCompleted
|
||||||
|
&& !await trafficPoll.WaitForNextTickAsync(traffic.Token).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await echo.Completion.ConfigureAwait(false);
|
||||||
|
output.Write(
|
||||||
|
"join.direct-traffic",
|
||||||
|
"verified",
|
||||||
|
phase: "direct-traffic",
|
||||||
|
endpointType: endpointType);
|
||||||
|
peer.Disconnect();
|
||||||
|
manager.PollEvents();
|
||||||
|
return TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"join.timeout",
|
||||||
|
"timed-out",
|
||||||
|
"The bounded join operation timed out.",
|
||||||
|
phase: "lifecycle",
|
||||||
|
outcome: ConnectionOutcomeKind.TimedOut.ToString());
|
||||||
|
if (coordinator is not null && !coordinator.IsCompleted)
|
||||||
|
{
|
||||||
|
coordinator.Poll();
|
||||||
|
}
|
||||||
|
if (coordinator is not null && !coordinator.IsCompleted)
|
||||||
|
{
|
||||||
|
coordinator.Cancel();
|
||||||
|
coordinator.Poll();
|
||||||
|
if (coordinator.Outcome is { } timeoutOutcome)
|
||||||
|
{
|
||||||
|
WriteOutcome(output, "join.traversal", timeoutOutcome);
|
||||||
|
WriteFallback(output, timeoutOutcome, listing.DedicatedFallback);
|
||||||
|
await ReportOutcomeAsync(
|
||||||
|
coordinator,
|
||||||
|
joins,
|
||||||
|
output,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return directConnected
|
||||||
|
? TestClientExitCode.DirectTrafficFailed
|
||||||
|
: TestClientExitCode.TraversalFailed;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
coordinator?.Dispose();
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SessionSelection> SelectListingAsync(
|
||||||
|
TestClientOptions options,
|
||||||
|
TestClientOutput output,
|
||||||
|
TextReader input,
|
||||||
|
RendezvousSessionBrowserClient browser,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (options.ListingId.HasValue)
|
||||||
|
{
|
||||||
|
RendezvousClientResult<GetSessionResponse> exact = await browser.GetAsync(
|
||||||
|
options.ListingId.Value,
|
||||||
|
options.GameId,
|
||||||
|
options.EnvironmentId,
|
||||||
|
options.ProtocolVersion,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!exact.IsSuccess || exact.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "join.selection", "directory", exact);
|
||||||
|
return new(null, TestClientExitCode.ServiceFailure);
|
||||||
|
}
|
||||||
|
return new(exact.Value.Session, TestClientExitCode.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(
|
||||||
|
BrowseRequest(options),
|
||||||
|
maximumPages: 10,
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
WriteServiceFailure(output, "join.selection", "directory", result);
|
||||||
|
return new(null, TestClientExitCode.ServiceFailure);
|
||||||
|
}
|
||||||
|
if (result.Value.Count == 0)
|
||||||
|
{
|
||||||
|
output.Write("join.selection", "empty", phase: "directory", count: 0);
|
||||||
|
return new(null, TestClientExitCode.NoCompatibleSession);
|
||||||
|
}
|
||||||
|
WriteListings(output, result.Value);
|
||||||
|
if (options.Script)
|
||||||
|
{
|
||||||
|
return new(result.Value[0], TestClientExitCode.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
output.WritePrompt($"Select session [1-{result.Value.Count}]: ");
|
||||||
|
string? selection = await input.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
SessionListing? selected = int.TryParse(selection, out int index)
|
||||||
|
&& index >= 1
|
||||||
|
&& index <= result.Value.Count
|
||||||
|
? result.Value[index - 1]
|
||||||
|
: null;
|
||||||
|
return selected is null
|
||||||
|
? new(null, TestClientExitCode.NoCompatibleSession)
|
||||||
|
: new(selected, TestClientExitCode.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteListings(TestClientOutput output, IReadOnlyList<SessionListing> listings)
|
||||||
|
{
|
||||||
|
output.Write("browse.completed", "complete", phase: "directory", count: listings.Count);
|
||||||
|
foreach (SessionListing listing in listings)
|
||||||
|
{
|
||||||
|
output.Write(
|
||||||
|
"browse.session",
|
||||||
|
"available",
|
||||||
|
phase: "directory",
|
||||||
|
listingId: listing.ListingId.ToString(),
|
||||||
|
displayName: listing.DisplayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BrowseSessionsRequest BrowseRequest(TestClientOptions options) => new()
|
||||||
|
{
|
||||||
|
GameId = options.GameId,
|
||||||
|
EnvironmentId = options.EnvironmentId,
|
||||||
|
ProtocolVersion = options.ProtocolVersion,
|
||||||
|
RegionId = options.RegionId,
|
||||||
|
PageSize = options.PageSize,
|
||||||
|
ExcludeFull = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient(TestClientOptions options) => new()
|
||||||
|
{
|
||||||
|
BaseAddress = options.ServiceUri,
|
||||||
|
Timeout = Timeout.InfiniteTimeSpan,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RendezvousClientOptions ClientOptions(TestClientOptions options) => new()
|
||||||
|
{
|
||||||
|
RequestTimeout = TimeSpan.FromSeconds(Math.Min(30, options.OperationTimeout.TotalSeconds)),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RendezvousCoordinatorOptions CoordinatorOptions(TestClientOptions options)
|
||||||
|
{
|
||||||
|
TimeSpan phaseTimeout = TimeSpan.FromSeconds(
|
||||||
|
Math.Min(30, options.OperationTimeout.TotalSeconds * 0.45));
|
||||||
|
return new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
PunchTimeout = phaseTimeout,
|
||||||
|
DirectConnectTimeout = phaseTimeout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancellationTokenSource CreateOperationTimeout(
|
||||||
|
TestClientOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CancellationTokenSource source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
source.CancelAfter(options.OperationTimeout);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ReportOutcomeAsync(
|
||||||
|
RendezvousClientCoordinator coordinator,
|
||||||
|
RendezvousJoinClient joins,
|
||||||
|
TestClientOutput output,
|
||||||
|
CancellationToken callerCancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource telemetry = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
callerCancellationToken);
|
||||||
|
telemetry.CancelAfter(TimeSpan.FromSeconds(5));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RendezvousClientResult<ReportConnectionOutcomeResponse> report =
|
||||||
|
await coordinator.ReportOutcomeAsync(joins, telemetry.Token).ConfigureAwait(false);
|
||||||
|
output.Write(
|
||||||
|
"join.outcome-report",
|
||||||
|
report.IsSuccess ? "accepted" : "failed",
|
||||||
|
phase: "telemetry",
|
||||||
|
message: report.IsSuccess ? null : SafeServiceMessage(report));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!callerCancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"join.outcome-report",
|
||||||
|
"cancelled",
|
||||||
|
"Outcome reporting was cancelled within the operation budget.",
|
||||||
|
phase: "telemetry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteOutcome(
|
||||||
|
TestClientOutput output,
|
||||||
|
string eventName,
|
||||||
|
RendezvousConnectionOutcome outcome) => output.Write(
|
||||||
|
eventName,
|
||||||
|
outcome.IsSuccess ? "connected" : "failed",
|
||||||
|
phase: outcome.Phase.ToString(),
|
||||||
|
outcome: outcome.Kind.ToString(),
|
||||||
|
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
|
||||||
|
|
||||||
|
private static void WriteFallback(
|
||||||
|
TestClientOutput output,
|
||||||
|
RendezvousConnectionOutcome outcome,
|
||||||
|
NetworkEndpoint? authoritativeFallback = null)
|
||||||
|
{
|
||||||
|
bool hasFallback = outcome.HasDedicatedFallback || authoritativeFallback is not null;
|
||||||
|
output.Write(
|
||||||
|
"join.fallback",
|
||||||
|
hasFallback ? "available" : "unavailable",
|
||||||
|
phase: "fallback",
|
||||||
|
outcome: outcome.Kind.ToString(),
|
||||||
|
endpointType: hasFallback ? "dedicated" : "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteServiceFailure<T>(
|
||||||
|
TestClientOutput output,
|
||||||
|
string eventName,
|
||||||
|
string phase,
|
||||||
|
RendezvousClientResult<T> result) => output.WriteError(
|
||||||
|
eventName,
|
||||||
|
"failed",
|
||||||
|
SafeServiceMessage(result),
|
||||||
|
phase,
|
||||||
|
result.Error.ToString());
|
||||||
|
|
||||||
|
private static string SafeServiceMessage<T>(RendezvousClientResult<T> result) =>
|
||||||
|
$"Rendezvous returned {result.Error}.";
|
||||||
|
|
||||||
|
private static async Task ObserveCancellationAsync<T>(Task<T>? task)
|
||||||
|
{
|
||||||
|
if (task is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EndpointType(IPAddress address)
|
||||||
|
{
|
||||||
|
if (IPAddress.IsLoopback(address))
|
||||||
|
{
|
||||||
|
return "loopback";
|
||||||
|
}
|
||||||
|
if (address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
byte[] ipv6 = address.GetAddressBytes();
|
||||||
|
return address.IsIPv6LinkLocal || (ipv6[0] & 0xfe) == 0xfc
|
||||||
|
? "private"
|
||||||
|
: "public";
|
||||||
|
}
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
bool privateAddress = bytes[0] == 10
|
||||||
|
|| bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|
||||||
|
|| bytes[0] == 192 && bytes[1] == 168;
|
||||||
|
return privateAddress ? "private" : "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ToMilliseconds(TimeSpan elapsed) =>
|
||||||
|
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
|
||||||
|
|
||||||
|
private sealed record SessionSelection(
|
||||||
|
SessionListing? Listing,
|
||||||
|
TestClientExitCode ExitCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
internal enum TestClientExitCode
|
||||||
|
{
|
||||||
|
Success = 0,
|
||||||
|
Usage = 2,
|
||||||
|
Configuration = 3,
|
||||||
|
ServiceFailure = 10,
|
||||||
|
NoCompatibleSession = 11,
|
||||||
|
TraversalFailed = 12,
|
||||||
|
DirectTrafficFailed = 13,
|
||||||
|
Cancelled = 130,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ITestClientCommandRunner
|
||||||
|
{
|
||||||
|
Task<TestClientExitCode> RunAsync(
|
||||||
|
TestClientOptions options,
|
||||||
|
TestClientOutput output,
|
||||||
|
TextReader input,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TestClientApplication(ITestClientCommandRunner runner)
|
||||||
|
{
|
||||||
|
private readonly ITestClientCommandRunner _runner = runner ?? throw new ArgumentNullException(nameof(runner));
|
||||||
|
|
||||||
|
internal async Task<int> RunAsync(
|
||||||
|
string[] args,
|
||||||
|
TextReader input,
|
||||||
|
TextWriter standardOutput,
|
||||||
|
TextWriter standardError,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool jsonRequested = args.Contains("--json", StringComparer.Ordinal);
|
||||||
|
TestClientParseResult parsed = TestClientOptionParser.Parse(args);
|
||||||
|
TestClientOutput output = new(standardOutput, standardError, jsonRequested);
|
||||||
|
if (parsed.ShowHelp)
|
||||||
|
{
|
||||||
|
if (jsonRequested)
|
||||||
|
{
|
||||||
|
output.Write(
|
||||||
|
"cli.help",
|
||||||
|
"complete",
|
||||||
|
phase: "configuration",
|
||||||
|
message: "Run without --json to read the full command reference.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await standardOutput.WriteLineAsync(TestClientOptionParser.Usage).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return (int)TestClientExitCode.Success;
|
||||||
|
}
|
||||||
|
if (!parsed.Succeeded || parsed.Options is null)
|
||||||
|
{
|
||||||
|
if (jsonRequested)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"cli.usage",
|
||||||
|
"failed",
|
||||||
|
parsed.Error ?? "Invalid command line.",
|
||||||
|
phase: "configuration");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await standardError.WriteLineAsync(parsed.Error ?? "Invalid command line.").ConfigureAwait(false);
|
||||||
|
await standardError.WriteLineAsync("Use --help for documented options.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return (int)TestClientExitCode.Usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
output = new TestClientOutput(standardOutput, standardError, parsed.Options.Json);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (int)await _runner.RunAsync(
|
||||||
|
parsed.Options,
|
||||||
|
output,
|
||||||
|
input,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
output.Write("lifecycle.cancelled", "cancelled", phase: "lifecycle");
|
||||||
|
return (int)TestClientExitCode.Cancelled;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
output.WriteError(
|
||||||
|
"lifecycle.failed",
|
||||||
|
"failed",
|
||||||
|
$"Unexpected {exception.GetType().Name}; credentials remain redacted.");
|
||||||
|
return (int)TestClientExitCode.ServiceFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
internal enum TestClientMode
|
||||||
|
{
|
||||||
|
Host,
|
||||||
|
Browse,
|
||||||
|
Join,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TestClientOptions
|
||||||
|
{
|
||||||
|
internal TestClientMode Mode { get; init; }
|
||||||
|
internal Uri ServiceUri { get; init; } = new("http://127.0.0.1:5000/");
|
||||||
|
internal IPEndPoint Mediator { get; init; } = new(IPAddress.Loopback, 9050);
|
||||||
|
internal GameId GameId { get; init; } = new("space-game");
|
||||||
|
internal EnvironmentId EnvironmentId { get; init; } = new("development");
|
||||||
|
internal RegionId RegionId { get; init; } = new("local");
|
||||||
|
internal uint ProtocolVersion { get; init; } = 1;
|
||||||
|
internal string BuildVersion { get; init; } = "test-client";
|
||||||
|
internal string DisplayName { get; init; } = "Rendezvous diagnostic host";
|
||||||
|
internal string PublisherCredentialEnvironmentVariable { get; init; } =
|
||||||
|
"RENDEZVOUS_PUBLISHER_CREDENTIAL";
|
||||||
|
internal Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
|
||||||
|
internal NetworkEndpoint? DedicatedFallback { get; init; }
|
||||||
|
internal SessionListingId? ListingId { get; init; }
|
||||||
|
internal int LocalPort { get; init; }
|
||||||
|
internal int PageSize { get; init; } = 20;
|
||||||
|
internal TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(20);
|
||||||
|
internal TimeSpan? RunDuration { get; init; }
|
||||||
|
internal bool Script { get; init; }
|
||||||
|
internal bool Json { get; init; }
|
||||||
|
internal bool ExitAfterEcho { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TestClientParseResult
|
||||||
|
{
|
||||||
|
private TestClientParseResult(TestClientOptions? options, string? error, bool showHelp)
|
||||||
|
{
|
||||||
|
Options = options;
|
||||||
|
Error = error;
|
||||||
|
ShowHelp = showHelp;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal TestClientOptions? Options { get; }
|
||||||
|
internal string? Error { get; }
|
||||||
|
internal bool ShowHelp { get; }
|
||||||
|
internal bool Succeeded => Options is not null;
|
||||||
|
|
||||||
|
internal static TestClientParseResult Success(TestClientOptions options) => new(options, null, false);
|
||||||
|
internal static TestClientParseResult Failure(string error) => new(null, error, false);
|
||||||
|
internal static TestClientParseResult Help() => new(null, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TestClientOptionParser
|
||||||
|
{
|
||||||
|
internal const string Usage = """
|
||||||
|
Rendezvous diagnostic client
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
rendezvous-test-client host [options]
|
||||||
|
rendezvous-test-client browse [options]
|
||||||
|
rendezvous-test-client join [options]
|
||||||
|
|
||||||
|
Common options:
|
||||||
|
--service URL HTTP(S) Rendezvous base URL
|
||||||
|
--mediator IP:PORT UDP mediator endpoint
|
||||||
|
--game ID Game scope (default: space-game)
|
||||||
|
--environment ID Environment scope (default: development)
|
||||||
|
--protocol NUMBER Exact gameplay protocol (default: 1)
|
||||||
|
--region ID Region filter/publication (default: local)
|
||||||
|
--timeout-seconds NUMBER Bounded startup/traversal stage, 1-300 (default: 20)
|
||||||
|
--port NUMBER Caller-owned gameplay UDP port; 0 chooses one
|
||||||
|
--page-size NUMBER Bounded browser page size, 1-100 (default: 20)
|
||||||
|
--script Never prompt; select the first compatible listing
|
||||||
|
--json Emit one versioned JSON event per line
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
Host options:
|
||||||
|
--publisher-credential-env NAME Environment variable containing the credential
|
||||||
|
--display-name TEXT Public listing name
|
||||||
|
--build-version TEXT Public build version
|
||||||
|
--metadata KEY=VALUE Bounded public metadata; may be repeated
|
||||||
|
--fallback IP:PORT Optional policy-authorized dedicated fallback
|
||||||
|
--run-seconds NUMBER Stop after 1-86400 seconds
|
||||||
|
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
|
||||||
|
|
||||||
|
Join options:
|
||||||
|
--listing UUID Join an exact listing; otherwise browse/select
|
||||||
|
|
||||||
|
Credentials are accepted only through the named environment variable. They are never
|
||||||
|
accepted on the command line and are never written to human or JSON output.
|
||||||
|
""";
|
||||||
|
|
||||||
|
internal static TestClientParseResult Parse(string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length == 0 || args.Length == 1 && IsHelp(args[0]))
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Help();
|
||||||
|
}
|
||||||
|
if (args.Length > 64)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("Too many command-line arguments.");
|
||||||
|
}
|
||||||
|
if (!TryMode(args[0], out TestClientMode mode))
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The first argument must be host, browse, or join.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri serviceUri = new("http://127.0.0.1:5000/");
|
||||||
|
IPEndPoint mediator = new(IPAddress.Loopback, 9050);
|
||||||
|
string game = "space-game";
|
||||||
|
string environment = "development";
|
||||||
|
string region = "local";
|
||||||
|
uint protocol = 1;
|
||||||
|
string buildVersion = "test-client";
|
||||||
|
string displayName = "Rendezvous diagnostic host";
|
||||||
|
string credentialEnvironmentVariable = "RENDEZVOUS_PUBLISHER_CREDENTIAL";
|
||||||
|
Dictionary<string, string> metadata = new(StringComparer.Ordinal);
|
||||||
|
NetworkEndpoint? dedicatedFallback = null;
|
||||||
|
SessionListingId? listingId = null;
|
||||||
|
int localPort = 0;
|
||||||
|
int pageSize = 20;
|
||||||
|
int timeoutSeconds = 20;
|
||||||
|
int? runSeconds = null;
|
||||||
|
bool script = false;
|
||||||
|
bool json = false;
|
||||||
|
bool exitAfterEcho = false;
|
||||||
|
HashSet<string> seen = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
for (int index = 1; index < args.Length; index++)
|
||||||
|
{
|
||||||
|
string option = args[index];
|
||||||
|
if (IsHelp(option))
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Help();
|
||||||
|
}
|
||||||
|
if (option is "--script" or "--json" or "--exit-after-echo")
|
||||||
|
{
|
||||||
|
if (!seen.Add(option))
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
|
||||||
|
}
|
||||||
|
script |= option == "--script";
|
||||||
|
json |= option == "--json";
|
||||||
|
exitAfterEcho |= option == "--exit-after-echo";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!option.StartsWith("--", StringComparison.Ordinal)
|
||||||
|
|| index + 1 >= args.Length)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("Every option must use the form --name value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string value = args[++index];
|
||||||
|
if (value.Length is 0 or > 512)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure($"Option {option} has an invalid value length.");
|
||||||
|
}
|
||||||
|
if (option != "--metadata" && !seen.Add(option))
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (option)
|
||||||
|
{
|
||||||
|
case "--service":
|
||||||
|
if (!TryServiceUri(value, out serviceUri))
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The service URL must be absolute HTTP(S), credential-free, and query-free.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "--mediator":
|
||||||
|
if (!IPEndPoint.TryParse(value, out IPEndPoint? parsedMediator)
|
||||||
|
|| parsedMediator.Port == 0)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The mediator must be an IP endpoint with a non-zero port.");
|
||||||
|
}
|
||||||
|
mediator = parsedMediator;
|
||||||
|
break;
|
||||||
|
case "--game":
|
||||||
|
game = value;
|
||||||
|
break;
|
||||||
|
case "--environment":
|
||||||
|
environment = value;
|
||||||
|
break;
|
||||||
|
case "--region":
|
||||||
|
region = value;
|
||||||
|
break;
|
||||||
|
case "--protocol":
|
||||||
|
if (!uint.TryParse(value, out protocol) || protocol == 0)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The protocol must be a positive integer.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "--build-version":
|
||||||
|
buildVersion = value;
|
||||||
|
break;
|
||||||
|
case "--display-name":
|
||||||
|
displayName = value;
|
||||||
|
break;
|
||||||
|
case "--publisher-credential-env":
|
||||||
|
if (!IsEnvironmentVariableName(value))
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The credential environment-variable name is invalid.");
|
||||||
|
}
|
||||||
|
credentialEnvironmentVariable = value;
|
||||||
|
break;
|
||||||
|
case "--metadata":
|
||||||
|
if (!TryMetadata(value, metadata))
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("Metadata must be a unique KEY=VALUE pair with a non-empty key.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "--fallback":
|
||||||
|
if (!IPEndPoint.TryParse(value, out IPEndPoint? fallbackEndpoint)
|
||||||
|
|| fallbackEndpoint.Port == 0)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The fallback must be an IP endpoint with a non-zero port.");
|
||||||
|
}
|
||||||
|
dedicatedFallback = new NetworkEndpoint
|
||||||
|
{
|
||||||
|
AddressFamily = fallbackEndpoint.AddressFamily == AddressFamily.InterNetwork
|
||||||
|
? AddressFamilyKind.Ipv4
|
||||||
|
: AddressFamilyKind.Ipv6,
|
||||||
|
Address = fallbackEndpoint.Address.ToString(),
|
||||||
|
Port = fallbackEndpoint.Port,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "--listing":
|
||||||
|
if (!Guid.TryParse(value, out Guid parsedListing) || parsedListing == Guid.Empty)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The listing must be a non-empty UUID.");
|
||||||
|
}
|
||||||
|
listingId = new SessionListingId(parsedListing);
|
||||||
|
break;
|
||||||
|
case "--port":
|
||||||
|
if (!int.TryParse(value, out localPort) || localPort is < 0 or > 65_535)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The local UDP port must be between 0 and 65535.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "--page-size":
|
||||||
|
if (!int.TryParse(value, out pageSize)
|
||||||
|
|| pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The page size is outside the contract limit.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "--timeout-seconds":
|
||||||
|
if (!int.TryParse(value, out timeoutSeconds) || timeoutSeconds is < 1 or > 300)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The timeout must be between 1 and 300 seconds.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "--run-seconds":
|
||||||
|
if (!int.TryParse(value, out int parsedRunSeconds)
|
||||||
|
|| parsedRunSeconds is < 1 or > 86_400)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("The host run duration must be between 1 and 86400 seconds.");
|
||||||
|
}
|
||||||
|
runSeconds = parsedRunSeconds;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return TestClientParseResult.Failure($"Unknown option {option}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsSlug(game, ContractLimits.GameIdMaxCharacters)
|
||||||
|
|| !IsSlug(environment, ContractLimits.EnvironmentIdMaxCharacters)
|
||||||
|
|| !IsSlug(region, ContractLimits.RegionIdMaxCharacters)
|
||||||
|
|| !ContractValidation.IsBuildVersionValid(buildVersion)
|
||||||
|
|| !ContractValidation.IsDisplayNameValid(displayName)
|
||||||
|
|| !ContractValidation.IsMetadataValid(metadata))
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
|
||||||
|
}
|
||||||
|
if (listingId.HasValue && mode != TestClientMode.Join
|
||||||
|
|| runSeconds.HasValue && mode != TestClientMode.Host
|
||||||
|
|| exitAfterEcho && mode != TestClientMode.Host
|
||||||
|
|| metadata.Count > 0 && mode != TestClientMode.Host
|
||||||
|
|| dedicatedFallback is not null && mode != TestClientMode.Host
|
||||||
|
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|
||||||
|
|| seen.Contains("--display-name") && mode != TestClientMode.Host
|
||||||
|
|| seen.Contains("--build-version") && mode != TestClientMode.Host)
|
||||||
|
{
|
||||||
|
return TestClientParseResult.Failure("One or more options do not apply to the selected mode.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return TestClientParseResult.Success(new TestClientOptions
|
||||||
|
{
|
||||||
|
Mode = mode,
|
||||||
|
ServiceUri = serviceUri,
|
||||||
|
Mediator = mediator,
|
||||||
|
GameId = new(game),
|
||||||
|
EnvironmentId = new(environment),
|
||||||
|
RegionId = new(region),
|
||||||
|
ProtocolVersion = protocol,
|
||||||
|
BuildVersion = buildVersion,
|
||||||
|
DisplayName = displayName,
|
||||||
|
PublisherCredentialEnvironmentVariable = credentialEnvironmentVariable,
|
||||||
|
Metadata = metadata,
|
||||||
|
DedicatedFallback = dedicatedFallback,
|
||||||
|
ListingId = listingId,
|
||||||
|
LocalPort = localPort,
|
||||||
|
PageSize = pageSize,
|
||||||
|
OperationTimeout = TimeSpan.FromSeconds(timeoutSeconds),
|
||||||
|
RunDuration = runSeconds.HasValue
|
||||||
|
? TimeSpan.FromSeconds(runSeconds.Value)
|
||||||
|
: mode == TestClientMode.Host && script
|
||||||
|
? TimeSpan.FromSeconds(timeoutSeconds)
|
||||||
|
: null,
|
||||||
|
Script = script,
|
||||||
|
Json = json,
|
||||||
|
ExitAfterEcho = exitAfterEcho,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryMode(string value, out TestClientMode mode) =>
|
||||||
|
Enum.TryParse(value, true, out mode) && Enum.IsDefined(mode);
|
||||||
|
|
||||||
|
private static bool IsHelp(string value) => value is "--help" or "-h" or "help";
|
||||||
|
|
||||||
|
private static bool TryServiceUri(string value, out Uri uri)
|
||||||
|
{
|
||||||
|
uri = null!;
|
||||||
|
if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed)
|
||||||
|
|| parsed.Scheme is not ("http" or "https")
|
||||||
|
|| !string.IsNullOrEmpty(parsed.UserInfo)
|
||||||
|
|| !string.IsNullOrEmpty(parsed.Query)
|
||||||
|
|| !string.IsNullOrEmpty(parsed.Fragment))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UriBuilder builder = new(parsed) { Path = parsed.AbsolutePath.TrimEnd('/') + "/" };
|
||||||
|
uri = builder.Uri;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEnvironmentVariableName(string value)
|
||||||
|
{
|
||||||
|
if (value.Length is 0 or > 64 || !(char.IsLetter(value[0]) || value[0] == '_'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value.All(static character =>
|
||||||
|
char.IsAsciiLetterOrDigit(character) || character == '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryMetadata(string value, Dictionary<string, string> metadata)
|
||||||
|
{
|
||||||
|
int separator = value.IndexOf('=');
|
||||||
|
if (separator is < 1 or > ContractLimits.MetadataKeyMaxBytes
|
||||||
|
|| metadata.Count >= ContractLimits.MetadataMaxKeys)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
string key = value[..separator];
|
||||||
|
string metadataValue = value[(separator + 1)..];
|
||||||
|
return !string.IsNullOrWhiteSpace(key)
|
||||||
|
&& ContractValidation.IsUtf8LengthWithin(key, ContractLimits.MetadataKeyMaxBytes)
|
||||||
|
&& ContractValidation.IsUtf8LengthWithin(metadataValue, ContractLimits.MetadataValueMaxBytes)
|
||||||
|
&& metadata.TryAdd(key, metadataValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSlug(string value, int maximumCharacters) =>
|
||||||
|
value.Length is > 0
|
||||||
|
&& value.Length <= maximumCharacters
|
||||||
|
&& value[0] is >= 'a' and <= 'z'
|
||||||
|
&& value.All(static character => character is >= 'a' and <= 'z'
|
||||||
|
or >= '0' and <= '9'
|
||||||
|
or '-');
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter standardError, bool json)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
WriteIndented = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly TextWriter _standardOutput = standardOutput ?? throw new ArgumentNullException(nameof(standardOutput));
|
||||||
|
private readonly TextWriter _standardError = standardError ?? throw new ArgumentNullException(nameof(standardError));
|
||||||
|
private readonly bool _json = json;
|
||||||
|
|
||||||
|
internal void Write(
|
||||||
|
string eventName,
|
||||||
|
string status,
|
||||||
|
string? phase = null,
|
||||||
|
string? listingId = null,
|
||||||
|
string? displayName = null,
|
||||||
|
string? outcome = null,
|
||||||
|
string? endpointType = null,
|
||||||
|
int? count = null,
|
||||||
|
long? elapsedMilliseconds = null,
|
||||||
|
string? message = null) => WriteCore(
|
||||||
|
_standardOutput,
|
||||||
|
new TestClientEvent
|
||||||
|
{
|
||||||
|
Event = SafeToken(eventName) ?? string.Empty,
|
||||||
|
Status = SafeToken(status) ?? string.Empty,
|
||||||
|
Phase = SafeToken(phase),
|
||||||
|
ListingId = SafeToken(listingId),
|
||||||
|
DisplayName = SafeText(displayName),
|
||||||
|
Outcome = SafeToken(outcome),
|
||||||
|
EndpointType = SafeToken(endpointType),
|
||||||
|
Count = count,
|
||||||
|
ElapsedMilliseconds = elapsedMilliseconds,
|
||||||
|
Message = SafeText(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
internal void WriteError(
|
||||||
|
string eventName,
|
||||||
|
string status,
|
||||||
|
string message,
|
||||||
|
string? phase = null,
|
||||||
|
string? outcome = null) => WriteCore(
|
||||||
|
_standardError,
|
||||||
|
new TestClientEvent
|
||||||
|
{
|
||||||
|
Event = SafeToken(eventName) ?? string.Empty,
|
||||||
|
Status = SafeToken(status) ?? string.Empty,
|
||||||
|
Phase = SafeToken(phase),
|
||||||
|
Outcome = SafeToken(outcome),
|
||||||
|
Message = SafeText(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
internal void WritePrompt(string prompt)
|
||||||
|
{
|
||||||
|
if (_json)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_standardOutput.Write(SafeText(prompt));
|
||||||
|
_standardOutput.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteCore(TextWriter writer, TestClientEvent item)
|
||||||
|
{
|
||||||
|
string line = _json
|
||||||
|
? JsonSerializer.Serialize(item, JsonOptions)
|
||||||
|
: HumanLine(item);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
writer.WriteLine(line);
|
||||||
|
writer.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HumanLine(TestClientEvent item)
|
||||||
|
{
|
||||||
|
StringBuilder line = new();
|
||||||
|
line.Append('[').Append(item.Status).Append("] ").Append(item.Event);
|
||||||
|
Append(line, "phase", item.Phase);
|
||||||
|
Append(line, "listing", item.ListingId);
|
||||||
|
Append(line, "name", item.DisplayName, quote: true);
|
||||||
|
Append(line, "outcome", item.Outcome);
|
||||||
|
Append(line, "endpoint", item.EndpointType);
|
||||||
|
if (item.Count.HasValue)
|
||||||
|
{
|
||||||
|
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
if (item.ElapsedMilliseconds.HasValue)
|
||||||
|
{
|
||||||
|
Append(
|
||||||
|
line,
|
||||||
|
"elapsedMs",
|
||||||
|
item.ElapsedMilliseconds.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
Append(line, "message", item.Message, quote: true);
|
||||||
|
return line.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Append(
|
||||||
|
StringBuilder builder,
|
||||||
|
string name,
|
||||||
|
string? value,
|
||||||
|
bool quote = false)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
builder.Append(' ').Append(name).Append('=');
|
||||||
|
if (quote)
|
||||||
|
{
|
||||||
|
builder.Append('"').Append(value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Append(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? SafeToken(string? value)
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new string(value
|
||||||
|
.Take(96)
|
||||||
|
.Select(static character => char.IsAsciiLetterOrDigit(character)
|
||||||
|
|| character is '.' or '-' or '_' or ':'
|
||||||
|
? char.ToLowerInvariant(character)
|
||||||
|
: '_')
|
||||||
|
.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? SafeText(string? value)
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new string(value
|
||||||
|
.Take(160)
|
||||||
|
.Select(static character => IsUnsafeHumanCharacter(character) ? '?' : character)
|
||||||
|
.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUnsafeHumanCharacter(char character) =>
|
||||||
|
char.GetUnicodeCategory(character) is
|
||||||
|
UnicodeCategory.Control
|
||||||
|
or UnicodeCategory.Format
|
||||||
|
or UnicodeCategory.LineSeparator
|
||||||
|
or UnicodeCategory.ParagraphSeparator
|
||||||
|
or UnicodeCategory.Surrogate
|
||||||
|
or UnicodeCategory.PrivateUse;
|
||||||
|
|
||||||
|
private sealed class TestClientEvent
|
||||||
|
{
|
||||||
|
public int Version { get; init; } = 1;
|
||||||
|
public string Event { get; init; } = string.Empty;
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
public string? Phase { get; init; }
|
||||||
|
public string? ListingId { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
public string? Outcome { get; init; }
|
||||||
|
public string? EndpointType { get; init; }
|
||||||
|
public int? Count { get; init; }
|
||||||
|
public long? ElapsedMilliseconds { get; init; }
|
||||||
|
public string? Message { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||||
|
|
||||||
|
public sealed class SessionBrowserServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ListEnforcesTenantProtocolPresenceVisibilityAndAvailabilityFilters()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing eligible = fixture.Add();
|
||||||
|
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
|
||||||
|
fixture.Add(scope: new(fixture.Scope.GameId, new("other-env")));
|
||||||
|
fixture.Add(protocolVersion: 8);
|
||||||
|
fixture.Add(regionId: new("us-east"));
|
||||||
|
fixture.Add(visibility: ListingVisibility.Unlisted);
|
||||||
|
fixture.Add(fresh: false);
|
||||||
|
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
request.ExcludeFull = true;
|
||||||
|
|
||||||
|
BrowserServiceResult<BrowseSessionsResponse> result = fixture.Browser.Browse(request);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.Collection(result.Value!.Items, item => Assert.Equal(eligible.Definition.ListingId, item.ListingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnguessableIdRetrievalAllowsFreshUnlistedOnlyWithinExactScope()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing unlisted = fixture.Add(visibility: ListingVisibility.Unlisted);
|
||||||
|
|
||||||
|
Assert.True(fixture.Browser.Get(
|
||||||
|
unlisted.Definition.ListingId,
|
||||||
|
fixture.Scope.GameId,
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
7).Succeeded);
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||||
|
unlisted.Definition.ListingId,
|
||||||
|
new("other-game"),
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
7).Error);
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||||
|
unlisted.Definition.ListingId,
|
||||||
|
fixture.Scope.GameId,
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
8).Error);
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(20));
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||||
|
unlisted.Definition.ListingId,
|
||||||
|
fixture.Scope.GameId,
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
7).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KeysetCursorReturnsStableRecordsOnceAndRejectsTamperingOrRescoping()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
for (int index = 0; index < 7; index++)
|
||||||
|
{
|
||||||
|
fixture.Add();
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowseSessionsRequest request = fixture.Request(pageSize: 2);
|
||||||
|
List<SessionListingId> seen = [];
|
||||||
|
do
|
||||||
|
{
|
||||||
|
BrowseSessionsResponse page = fixture.Browser.Browse(request).Value!;
|
||||||
|
seen.AddRange(page.Items.Select(static item => item.ListingId));
|
||||||
|
request.Cursor = page.NextCursor;
|
||||||
|
}
|
||||||
|
while (request.Cursor is not null);
|
||||||
|
|
||||||
|
Assert.Equal(7, seen.Count);
|
||||||
|
Assert.Equal(7, seen.Distinct().Count());
|
||||||
|
Assert.Equal(seen.OrderBy(static id => id.Value), seen);
|
||||||
|
|
||||||
|
BrowseSessionsRequest tampered = fixture.Request(pageSize: 2);
|
||||||
|
tampered.Cursor = fixture.Browser.Browse(tampered).Value!.NextCursor + "A";
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(tampered).Error);
|
||||||
|
BrowseSessionsRequest rescoped = fixture.Request(pageSize: 2);
|
||||||
|
rescoped.Cursor = fixture.Browser.Browse(fixture.Request(pageSize: 2)).Value!.NextCursor;
|
||||||
|
rescoped.ExcludeFull = true;
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(rescoped).Error);
|
||||||
|
|
||||||
|
BrowseSessionsRequest expired = fixture.Request(pageSize: 2);
|
||||||
|
expired.Cursor = fixture.Browser.Browse(expired).Value!.NextCursor;
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromMinutes(5));
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(expired).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResponseByteBudgetTrimsLargePagesAndContinuesWithCursor()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
Dictionary<string, string> metadata = Enumerable.Range(0, 14).ToDictionary(
|
||||||
|
static index => $"key-{index}",
|
||||||
|
static index => new string((char)('a' + index % 26), 256),
|
||||||
|
EqualityComparer<string>.Default);
|
||||||
|
for (int index = 0; index < 100; index++)
|
||||||
|
{
|
||||||
|
fixture.Add(metadata: metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
|
||||||
|
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||||
|
|
||||||
|
Assert.InRange(encodedBytes, 1, ContractLimits.BrowserResponseMaxBytes);
|
||||||
|
Assert.NotEmpty(response.Items);
|
||||||
|
Assert.NotNull(response.NextCursor);
|
||||||
|
Assert.True(response.Items.Count < 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresentationMetadataIsJsonEscapedAndResponseHasNoConnectionSecrets()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
fixture.Add(metadata: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mode"] = "co-op",
|
||||||
|
["map"] = "<script>alert(1)</script>",
|
||||||
|
});
|
||||||
|
|
||||||
|
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
|
||||||
|
string json = JsonSerializer.Serialize(response, ContractJson.Options);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("<script>", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("token", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("capability", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal("<script>alert(1)</script>", Assert.Single(response.Items).Metadata["map"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RevokedListingDisappearsBeforeAnotherReadPathCanObserveIt()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
fixture.Store.RevokeListing(listing.Definition.ListingId);
|
||||||
|
|
||||||
|
Assert.Empty(fixture.Browser.Browse(fixture.Request()).Value!.Items);
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
fixture.Scope.GameId,
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
7).Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||||
|
|
||||||
|
internal sealed class SessionBrowserFixture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly EphemeralStateFixture _state = new();
|
||||||
|
|
||||||
|
public SessionBrowserFixture()
|
||||||
|
{
|
||||||
|
Cursors = new();
|
||||||
|
Browser = new(_state.Store, Cursors, _state.Clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InMemoryEphemeralRendezvousStore Store => _state.Store;
|
||||||
|
public ManualRendezvousClock Clock => _state.Clock;
|
||||||
|
public SessionBrowserCursorCodec Cursors { get; }
|
||||||
|
public SessionBrowserService Browser { get; }
|
||||||
|
public TenantScope Scope => _state.Scope;
|
||||||
|
|
||||||
|
public StoredListing Add(
|
||||||
|
TenantScope? scope = null,
|
||||||
|
uint protocolVersion = 7,
|
||||||
|
RegionId? regionId = null,
|
||||||
|
ListingVisibility visibility = ListingVisibility.Public,
|
||||||
|
bool fresh = true,
|
||||||
|
int currentPlayers = 1,
|
||||||
|
int maximumPlayers = 8,
|
||||||
|
IReadOnlyDictionary<string, string>? metadata = null)
|
||||||
|
{
|
||||||
|
CreateListingCommand seed = _state.ListingCommand();
|
||||||
|
CreateListingCommand command = seed with
|
||||||
|
{
|
||||||
|
Listing = seed.Listing with
|
||||||
|
{
|
||||||
|
Scope = scope ?? Scope,
|
||||||
|
ProtocolVersion = protocolVersion,
|
||||||
|
RegionId = regionId ?? seed.Listing.RegionId,
|
||||||
|
Visibility = visibility,
|
||||||
|
CurrentPlayers = currentPlayers,
|
||||||
|
MaximumPlayers = maximumPlayers,
|
||||||
|
Metadata = metadata ?? seed.Listing.Metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
StoredListing listing = Store.CreateListing(command).Value!;
|
||||||
|
if (fresh)
|
||||||
|
{
|
||||||
|
listing = Store.BindHostPresence(new(
|
||||||
|
command.Listing.HostPresenceHandle,
|
||||||
|
command.Listing.HostPresenceFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(40_000),
|
||||||
|
null)).Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BrowseSessionsRequest Request(int pageSize = 100) => new()
|
||||||
|
{
|
||||||
|
GameId = Scope.GameId,
|
||||||
|
EnvironmentId = Scope.EnvironmentId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
PageSize = pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
public void Dispose() => Cursors.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class ConnectionTicketValidatorTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuthorizedTicketIsAcceptedExactlyOnce()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||||
|
JoinAttemptId attempt = NewAttempt();
|
||||||
|
string ticket = Ticket('A');
|
||||||
|
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||||
|
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Accepted,
|
||||||
|
validator.Consume(attempt, ticket));
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.AlreadyConsumed,
|
||||||
|
validator.Consume(attempt, ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlteredCrossAttemptExpiredAndRevokedTicketsAreRejected()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||||
|
JoinAttemptId first = NewAttempt();
|
||||||
|
JoinAttemptId second = NewAttempt();
|
||||||
|
Assert.True(validator.TryAuthorize(first, Ticket('A'), Now.AddSeconds(20)));
|
||||||
|
Assert.True(validator.TryAuthorize(second, Ticket('B'), Now.AddSeconds(40)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Rejected,
|
||||||
|
validator.Consume(first, Ticket('B')));
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(20));
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Expired,
|
||||||
|
validator.Consume(first, Ticket('A')));
|
||||||
|
Assert.True(validator.Revoke(second));
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Revoked,
|
||||||
|
validator.Consume(second, Ticket('B')));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentConsumptionHasOneWinner()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||||
|
JoinAttemptId attempt = NewAttempt();
|
||||||
|
string ticket = Ticket('C');
|
||||||
|
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||||
|
using ManualResetEventSlim start = new(false);
|
||||||
|
Task<ConnectionTicketConsumptionResult> left = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return validator.Consume(attempt, ticket);
|
||||||
|
});
|
||||||
|
Task<ConnectionTicketConsumptionResult> right = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return validator.Consume(attempt, ticket);
|
||||||
|
});
|
||||||
|
|
||||||
|
start.Set();
|
||||||
|
ConnectionTicketConsumptionResult[] results = await Task.WhenAll(left, right);
|
||||||
|
|
||||||
|
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.Accepted);
|
||||||
|
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.AlreadyConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidatorIsBoundedDisposableAndRedacted()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
ConnectionTicketValidator validator = new(1, clock);
|
||||||
|
Assert.True(validator.TryAuthorize(NewAttempt(), Ticket('A'), Now.AddSeconds(20)));
|
||||||
|
Assert.False(validator.TryAuthorize(NewAttempt(), Ticket('B'), Now.AddSeconds(20)));
|
||||||
|
Assert.DoesNotContain(Ticket('A'), validator.ToString(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
validator.Dispose();
|
||||||
|
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => validator.Revoke(NewAttempt()));
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => validator.Consume(default, string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JoinAttemptId NewAttempt() => new(Guid.NewGuid());
|
||||||
|
private static string Ticket(char value) => new(value, 43);
|
||||||
|
|
||||||
|
private sealed class ManualConnectionTicketClock : IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; set; } = Now;
|
||||||
|
|
||||||
|
public void Advance(TimeSpan duration) => UtcNow += duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientBehaviorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RegistrationRetriesWithTheSameIdempotentPayloadAndDisposesResponses()
|
||||||
|
{
|
||||||
|
TrackingContent unavailable = JsonContent(new ApiError
|
||||||
|
{
|
||||||
|
Code = RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
Message = "try later",
|
||||||
|
RetryAfterSeconds = 1,
|
||||||
|
});
|
||||||
|
TrackingContent created = JsonContent(CreateRegistrationResponse());
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.ServiceUnavailable, unavailable),
|
||||||
|
Response(HttpStatusCode.Created, created),
|
||||||
|
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RegisterSessionRequest request = CreateRegistrationRequest("stable-idempotency-key");
|
||||||
|
RecordingDelay delay = new(() => request.DisplayName = "mutated during retry delay");
|
||||||
|
RendezvousPublisherClient publisher = new(
|
||||||
|
httpClient,
|
||||||
|
new RendezvousClientOptions { JitterRatio = 0 },
|
||||||
|
delay);
|
||||||
|
|
||||||
|
RendezvousClientResult<PublishedSession> result = await publisher.RegisterAsync(
|
||||||
|
request,
|
||||||
|
"publisher-credential");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, handler.RequestBodies.Count);
|
||||||
|
Assert.Equal(handler.RequestBodies[0], handler.RequestBodies[1]);
|
||||||
|
Assert.Contains("stable-idempotency-key", handler.RequestBodies[0], StringComparison.Ordinal);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(1), Assert.Single(delay.Delays));
|
||||||
|
Assert.True(unavailable.IsDisposed);
|
||||||
|
Assert.True(created.IsDisposed);
|
||||||
|
|
||||||
|
using HttpResponseMessage stillOwnedByCaller = await httpClient.GetAsync("health");
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, stillOwnedByCaller.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateUsesTheLeaseWithoutMutatingTheCallersRequest()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
|
||||||
|
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousPublisherClient publisher = new(httpClient);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistrationRequest("update-idempotency-key"),
|
||||||
|
"publisher-credential"));
|
||||||
|
UpdateSessionRequest update = new()
|
||||||
|
{
|
||||||
|
LeaseToken = "caller-placeholder",
|
||||||
|
BuildVersion = "2.0.0",
|
||||||
|
DisplayName = "updated",
|
||||||
|
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 4 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
};
|
||||||
|
|
||||||
|
RendezvousClientResult<bool> result = await publisher.UpdateAsync(
|
||||||
|
session,
|
||||||
|
update,
|
||||||
|
"publisher-credential");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("caller-placeholder", update.LeaseToken);
|
||||||
|
Assert.Contains("lease-token", handler.RequestBodies[1], StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("caller-placeholder", handler.RequestBodies[1], StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GatewayFailureIsRetriedForSafeBrowserReads()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
new HttpResponseMessage(HttpStatusCode.BadGateway),
|
||||||
|
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse())));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RecordingDelay delay = new();
|
||||||
|
RendezvousSessionBrowserClient browser = new(
|
||||||
|
httpClient,
|
||||||
|
new RendezvousClientOptions { JitterRatio = 0 },
|
||||||
|
delay);
|
||||||
|
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> result = await browser.BrowseAsync(new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
Assert.Equal(2, handler.RequestUris.Count);
|
||||||
|
Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SilentServiceIsBoundedByTheConfiguredRequestTimeout()
|
||||||
|
{
|
||||||
|
using HttpClient httpClient = new(new SilentHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = new("http://rendezvous.test/"),
|
||||||
|
};
|
||||||
|
RendezvousSessionBrowserClient browser = new(
|
||||||
|
httpClient,
|
||||||
|
new RendezvousClientOptions
|
||||||
|
{
|
||||||
|
MaximumSafeRetries = 0,
|
||||||
|
RequestTimeout = TimeSpan.FromMilliseconds(20),
|
||||||
|
JitterRatio = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> result = await browser.BrowseAsync(new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
}).WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SuccessResultRequiresAValue()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => RendezvousClientResult.Success<string>(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BrowseAllFollowsCursorsWithoutMutatingTheCallersRequest()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
|
||||||
|
{
|
||||||
|
Items = [CreateListing("00000000-0000-0000-0000-000000000001")],
|
||||||
|
NextCursor = "next page+token",
|
||||||
|
})),
|
||||||
|
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
|
||||||
|
{
|
||||||
|
Items = [CreateListing("00000000-0000-0000-0000-000000000002")],
|
||||||
|
})));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||||
|
BrowseSessionsRequest request = new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
PageSize = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(request);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value!.Count);
|
||||||
|
Assert.Null(request.Cursor);
|
||||||
|
Assert.DoesNotContain("cursor=", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("cursor=next%20page%2Btoken", handler.RequestUris[1].Query, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LeaseMaintainerReportsLeaseLoss()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
|
||||||
|
Response(HttpStatusCode.Gone, JsonContent(new ApiError
|
||||||
|
{
|
||||||
|
Code = RendezvousErrorCode.Expired,
|
||||||
|
Message = "lease expired",
|
||||||
|
})));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RecordingDelay delay = new();
|
||||||
|
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistrationRequest("lease-loss-key"),
|
||||||
|
"publisher-credential"));
|
||||||
|
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||||
|
session,
|
||||||
|
"publisher-credential");
|
||||||
|
bool eventRaised = false;
|
||||||
|
maintainer.LeaseLost += (_, _) => eventRaised = true;
|
||||||
|
|
||||||
|
LeaseMaintenanceResult result = await maintainer.RunAsync();
|
||||||
|
|
||||||
|
Assert.Equal(LeaseMaintenanceStopReason.LeaseLost, result.Reason);
|
||||||
|
Assert.Equal(RendezvousErrorCode.Expired, result.Error);
|
||||||
|
Assert.True(eventRaised);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(15), Assert.Single(delay.Delays));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposingLeaseMaintainerCancelsItsWaitAndDoesNotRenew()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
BlockingDelay delay = new();
|
||||||
|
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistrationRequest("dispose-key"),
|
||||||
|
"publisher-credential"));
|
||||||
|
SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||||
|
session,
|
||||||
|
"publisher-credential");
|
||||||
|
Task<LeaseMaintenanceResult> active = maintainer.RunAsync();
|
||||||
|
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await maintainer.DisposeAsync();
|
||||||
|
LeaseMaintenanceResult result = await active;
|
||||||
|
|
||||||
|
Assert.Equal(LeaseMaintenanceStopReason.Disposed, result.Reason);
|
||||||
|
Assert.Single(handler.RequestUris);
|
||||||
|
await Assert.ThrowsAsync<ObjectDisposedException>(() => maintainer.RunAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallerCancellationStopsLeaseMaintenanceWithoutRenewing()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
BlockingDelay delay = new();
|
||||||
|
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistrationRequest("cancel-key"),
|
||||||
|
"publisher-credential"));
|
||||||
|
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||||
|
session,
|
||||||
|
"publisher-credential");
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
Task<LeaseMaintenanceResult> active = maintainer.RunAsync(cancellation.Token);
|
||||||
|
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await cancellation.CancelAsync();
|
||||||
|
LeaseMaintenanceResult result = await active;
|
||||||
|
|
||||||
|
Assert.Equal(LeaseMaintenanceStopReason.Cancelled, result.Reason);
|
||||||
|
Assert.Single(handler.RequestUris);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
return Assert.IsType<T>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CreateRegistrationRequest(string idempotencyKey) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = idempotencyKey,
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "SDK host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RegisterSessionResponse CreateRegistrationResponse() => new()
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000010")),
|
||||||
|
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000011")),
|
||||||
|
LeaseToken = "lease-token",
|
||||||
|
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000012")),
|
||||||
|
HostPresenceCapability = "presence-capability",
|
||||||
|
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||||
|
LeaseRenewAfterSeconds = 15,
|
||||||
|
HostPresenceRefreshAfterSeconds = 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SessionListing CreateListing(string id) => new()
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.Parse(id)),
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
PublisherTrustMode = PublisherTrustMode.ManagedDedicated,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static TrackingContent JsonContent<T>(T value) => new(
|
||||||
|
JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options));
|
||||||
|
|
||||||
|
private static HttpResponseMessage Response(HttpStatusCode status, HttpContent content) => new(status)
|
||||||
|
{
|
||||||
|
Content = content,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class ScriptedHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||||
|
|
||||||
|
internal List<string> RequestBodies { get; } = [];
|
||||||
|
internal List<Uri> RequestUris { get; } = [];
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
RequestUris.Add(request.RequestUri!);
|
||||||
|
RequestBodies.Add(request.Content is null
|
||||||
|
? string.Empty
|
||||||
|
: await request.Content.ReadAsStringAsync(cancellationToken));
|
||||||
|
return _responses.Count > 0
|
||||||
|
? _responses.Dequeue()
|
||||||
|
: throw new InvalidOperationException("No scripted response remains.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TrackingContent(byte[] bytes) : HttpContent
|
||||||
|
{
|
||||||
|
internal bool IsDisposed { get; private set; }
|
||||||
|
|
||||||
|
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||||
|
stream.WriteAsync(bytes).AsTask();
|
||||||
|
|
||||||
|
protected override bool TryComputeLength(out long length)
|
||||||
|
{
|
||||||
|
length = bytes.Length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
IsDisposed = true;
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingDelay(Action? onDelay = null) : IRendezvousDelay
|
||||||
|
{
|
||||||
|
internal List<TimeSpan> Delays { get; } = [];
|
||||||
|
|
||||||
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
Delays.Add(delay);
|
||||||
|
onDelay?.Invoke();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingDelay : IRendezvousDelay
|
||||||
|
{
|
||||||
|
internal TaskCompletionSource Started { get; } = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Started.TrySetResult();
|
||||||
|
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SilentHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientIntegrationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
|
||||||
|
{
|
||||||
|
await using ClientTestHost host = await ClientTestHost.StartAsync();
|
||||||
|
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||||
|
RendezvousSessionBrowserClient browser = new(host.HttpClient);
|
||||||
|
List<PublishedSession> sessions = [];
|
||||||
|
|
||||||
|
for (int index = 0; index < 3; index++)
|
||||||
|
{
|
||||||
|
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(index),
|
||||||
|
host.PublisherCredential);
|
||||||
|
PublishedSession session = AssertSuccess(registered);
|
||||||
|
sessions.Add(session);
|
||||||
|
Assert.True(host.Capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint fingerprint));
|
||||||
|
StoreResult<StoredListing> bound = host.Store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
fingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, $"203.0.113.{80 + index}", 41_000 + index),
|
||||||
|
null));
|
||||||
|
Assert.Equal(StoreResultCode.Success, bound.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishedSession first = sessions[0];
|
||||||
|
RendezvousClientResult<RenewLeaseResponse> renewed = await publisher.RenewAsync(
|
||||||
|
first,
|
||||||
|
host.PublisherCredential);
|
||||||
|
Assert.True(renewed.IsSuccess, renewed.Message);
|
||||||
|
Assert.Equal(renewed.Value!.ExpiresAt, first.ExpiresAt);
|
||||||
|
|
||||||
|
UpdateSessionRequest update = new()
|
||||||
|
{
|
||||||
|
BuildVersion = "2.0.0",
|
||||||
|
DisplayName = "SDK host updated",
|
||||||
|
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
};
|
||||||
|
RendezvousClientResult<bool> updated = await publisher.UpdateAsync(
|
||||||
|
first,
|
||||||
|
update,
|
||||||
|
host.PublisherCredential);
|
||||||
|
Assert.True(updated.IsSuccess, updated.Message);
|
||||||
|
|
||||||
|
BrowseSessionsRequest browseRequest = new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
PageSize = 1,
|
||||||
|
ExcludeFull = true,
|
||||||
|
};
|
||||||
|
IReadOnlyList<SessionListing> listings = AssertSuccess(
|
||||||
|
await browser.BrowseAllAsync(browseRequest));
|
||||||
|
Assert.Equal(3, listings.Count);
|
||||||
|
Assert.Equal("SDK host updated", listings.Single(item => item.ListingId == first.ListingId).DisplayName);
|
||||||
|
|
||||||
|
GetSessionResponse direct = AssertSuccess(await browser.GetAsync(
|
||||||
|
first.ListingId,
|
||||||
|
new("space-game"),
|
||||||
|
new("production"),
|
||||||
|
7));
|
||||||
|
Assert.Equal("2.0.0", direct.Session.BuildVersion);
|
||||||
|
|
||||||
|
foreach (PublishedSession session in sessions)
|
||||||
|
{
|
||||||
|
RendezvousClientResult<bool> deregistered = await publisher.DeregisterAsync(
|
||||||
|
session,
|
||||||
|
host.PublisherCredential);
|
||||||
|
Assert.True(deregistered.IsSuccess, deregistered.Message);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(session.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
return Assert.IsAssignableFrom<T>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CreateRegistration(int index) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = $"sdk-integration-{index}",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = $"SDK host {index}",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class ClientTestHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _application;
|
||||||
|
|
||||||
|
private ClientTestHost(
|
||||||
|
WebApplication application,
|
||||||
|
HttpClient httpClient,
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
EphemeralCapabilityIssuer capabilities,
|
||||||
|
string publisherCredential)
|
||||||
|
{
|
||||||
|
_application = application;
|
||||||
|
HttpClient = httpClient;
|
||||||
|
Store = store;
|
||||||
|
Capabilities = capabilities;
|
||||||
|
PublisherCredential = publisherCredential;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HttpClient HttpClient { get; }
|
||||||
|
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||||
|
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||||
|
internal string PublisherCredential { get; }
|
||||||
|
|
||||||
|
internal static async Task<ClientTestHost> StartAsync()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
|
EphemeralStoreOptions stateOptions = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||||
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
|
ProvisioningTestData.CreateOptions(),
|
||||||
|
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||||
|
clock.UtcNow);
|
||||||
|
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||||
|
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||||
|
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
|
options.ThrowOnBadRequest = true);
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||||
|
builder.Services.AddSingleton(provisioning);
|
||||||
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(clock);
|
||||||
|
builder.Services.AddSingleton(capabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||||
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.MapRendezvousContractEndpoints();
|
||||||
|
await app.StartAsync();
|
||||||
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
|
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
|
return new(
|
||||||
|
app,
|
||||||
|
new HttpClient { BaseAddress = new Uri(address) },
|
||||||
|
store,
|
||||||
|
capabilities,
|
||||||
|
credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
HttpClient.Dispose();
|
||||||
|
await _application.StopAsync();
|
||||||
|
await _application.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,885 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousCoordinatorBehaviorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LegacyCompletionConstructorsRemainCompatibleWithoutAllowingNonterminalStates()
|
||||||
|
{
|
||||||
|
#pragma warning disable CS0618
|
||||||
|
RendezvousConnectionCompletedEventArgs client = new(
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
(NetPeer?)null);
|
||||||
|
RendezvousHostAttemptCompletedEventArgs host = new(
|
||||||
|
new JoinAttemptId(Guid.NewGuid()),
|
||||||
|
RendezvousConnectionState.ManagerStopped,
|
||||||
|
(NetPeer?)null);
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||||
|
new RendezvousConnectionCompletedEventArgs(
|
||||||
|
RendezvousConnectionState.Punching,
|
||||||
|
(NetPeer?)null));
|
||||||
|
Assert.Throws<ArgumentException>(() =>
|
||||||
|
new RendezvousHostAttemptCompletedEventArgs(
|
||||||
|
default,
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
(NetPeer?)null));
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.HostRejected, client.Outcome.Kind);
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.ManagerStopped, host.Outcome.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(RendezvousErrorCode.NotFound, ConnectionOutcomeKind.DirectoryNotFound, RendezvousConnectionFailureCategory.Directory)]
|
||||||
|
[InlineData(RendezvousErrorCode.Expired, ConnectionOutcomeKind.AttemptExpired, RendezvousConnectionFailureCategory.Authorization)]
|
||||||
|
[InlineData(RendezvousErrorCode.IncompatibleProtocol, ConnectionOutcomeKind.IncompatibleProtocol, RendezvousConnectionFailureCategory.Compatibility)]
|
||||||
|
[InlineData(RendezvousErrorCode.Forbidden, ConnectionOutcomeKind.Unauthorized, RendezvousConnectionFailureCategory.Authorization)]
|
||||||
|
[InlineData(RendezvousErrorCode.RateLimited, ConnectionOutcomeKind.RateLimited, RendezvousConnectionFailureCategory.Capacity)]
|
||||||
|
[InlineData(RendezvousErrorCode.StaleHost, ConnectionOutcomeKind.NoHostPresence, RendezvousConnectionFailureCategory.HostPresence)]
|
||||||
|
[InlineData(RendezvousErrorCode.ServiceUnavailable, ConnectionOutcomeKind.ServiceUnavailable, RendezvousConnectionFailureCategory.Service)]
|
||||||
|
public void AuthoritativeServiceErrorsMapToStableConnectionOutcomes(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
ConnectionOutcomeKind expectedKind,
|
||||||
|
RendezvousConnectionFailureCategory expectedCategory)
|
||||||
|
{
|
||||||
|
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.FromServiceError(
|
||||||
|
error,
|
||||||
|
TimeSpan.FromMilliseconds(250));
|
||||||
|
|
||||||
|
Assert.Equal(expectedKind, outcome.Kind);
|
||||||
|
Assert.Equal(expectedCategory, outcome.Category);
|
||||||
|
Assert.Equal(RendezvousConnectionOutcomeSource.RendezvousService, outcome.Source);
|
||||||
|
Assert.Equal(error, outcome.ServiceError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NatIntroductionAloneDoesNotCompleteTheClientAttempt()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock);
|
||||||
|
int completions = 0;
|
||||||
|
harness.Coordinator.Completed += (_, _) => completions++;
|
||||||
|
|
||||||
|
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
NatAddressType.External,
|
||||||
|
harness.IntroductionToken);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Connecting, harness.Coordinator.State);
|
||||||
|
Assert.False(harness.Coordinator.IsCompleted);
|
||||||
|
Assert.Equal(0, completions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClientRejectsASyntacticallyValidIntroductionWithTheWrongTicket()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock);
|
||||||
|
string forged = NatIntroductionTokenCodec.Encode(
|
||||||
|
harness.AttemptId,
|
||||||
|
Credential('F'));
|
||||||
|
|
||||||
|
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
NatAddressType.External,
|
||||||
|
forged);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Punching, harness.Coordinator.State);
|
||||||
|
Assert.False(harness.Coordinator.IsCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MediatorNetworkErrorProducesOneTypedTerminalOutcome()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock);
|
||||||
|
int completions = 0;
|
||||||
|
harness.Coordinator.Completed += (_, _) => completions++;
|
||||||
|
|
||||||
|
harness.NetworkEvents.OnNetworkError(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_001),
|
||||||
|
System.Net.Sockets.SocketError.HostUnreachable);
|
||||||
|
RendezvousConnectionOutcome outcome = Assert.IsType<RendezvousConnectionOutcome>(
|
||||||
|
harness.Coordinator.Outcome);
|
||||||
|
harness.NetworkEvents.OnNetworkError(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_001),
|
||||||
|
System.Net.Sockets.SocketError.HostUnreachable);
|
||||||
|
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.MediatorUnavailable, outcome.Kind);
|
||||||
|
Assert.Equal(RendezvousConnectionFailureCategory.Mediation, outcome.Category);
|
||||||
|
Assert.Equal(1, completions);
|
||||||
|
Assert.Same(outcome, harness.Coordinator.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock);
|
||||||
|
List<RendezvousConnectionState> completions = [];
|
||||||
|
harness.Coordinator.Completed += (_, completion) => completions.Add(completion.State);
|
||||||
|
|
||||||
|
harness.Coordinator.Cancel();
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
NatAddressType.External,
|
||||||
|
harness.IntroductionToken);
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State);
|
||||||
|
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.Cancelled, harness.Coordinator.Outcome!.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExhaustedPunchBudgetTimesOutExactlyOnceUnderAFakeClock()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
MaximumPunchRequests = 1,
|
||||||
|
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||||
|
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||||
|
JitterRatio = 0,
|
||||||
|
});
|
||||||
|
int completions = 0;
|
||||||
|
harness.Coordinator.Completed += (_, _) => completions++;
|
||||||
|
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
clock.Advance(TimeSpan.FromMilliseconds(10));
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
clock.Advance(TimeSpan.FromMinutes(1));
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State);
|
||||||
|
Assert.Equal(1, completions);
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, harness.Coordinator.Outcome!.Kind);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousConnectionFailureCategory.NatTraversal,
|
||||||
|
harness.Coordinator.Outcome.Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WallClockRollbackCannotExtendTheMonotonicPunchDeadline()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
PunchTimeout = TimeSpan.FromSeconds(10),
|
||||||
|
JitterRatio = 0,
|
||||||
|
});
|
||||||
|
clock.AdjustWallClock(TimeSpan.FromHours(-1));
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(11));
|
||||||
|
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, harness.Coordinator.Outcome!.Kind);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(11), harness.Coordinator.Outcome.Elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectConnectTimeoutOffersFallbackWithoutConnectingIt()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
NetworkEndpoint fallback = new()
|
||||||
|
{
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
Address = "203.0.113.90",
|
||||||
|
Port = 9_060,
|
||||||
|
};
|
||||||
|
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
DirectConnectTimeout = TimeSpan.FromMilliseconds(10),
|
||||||
|
DedicatedFallbackOverride = fallback,
|
||||||
|
JitterRatio = 0,
|
||||||
|
});
|
||||||
|
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
NatAddressType.External,
|
||||||
|
harness.IntroductionToken);
|
||||||
|
clock.Advance(TimeSpan.FromMilliseconds(10));
|
||||||
|
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
|
||||||
|
RendezvousConnectionOutcome outcome = Assert.IsType<RendezvousConnectionOutcome>(
|
||||||
|
harness.Coordinator.Outcome);
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.DirectConnectTimedOut, outcome.Kind);
|
||||||
|
Assert.Equal(RendezvousConnectionPhase.DirectConnection, outcome.Phase);
|
||||||
|
Assert.Equal("203.0.113.90", outcome.DedicatedFallback!.Address);
|
||||||
|
List<NetPeer> connectedPeers = [];
|
||||||
|
harness.Manager.GetConnectedPeers(connectedPeers);
|
||||||
|
Assert.Empty(connectedPeers);
|
||||||
|
|
||||||
|
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_001),
|
||||||
|
NatAddressType.External,
|
||||||
|
harness.IntroductionToken);
|
||||||
|
Assert.Same(outcome, harness.Coordinator.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManagerShutdownAndDisposalEachReleaseTheirTerminalPathOnce()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness stopped = new(clock);
|
||||||
|
int stoppedCompletions = 0;
|
||||||
|
stopped.Coordinator.Completed += (_, _) => stoppedCompletions++;
|
||||||
|
stopped.Manager.Stop();
|
||||||
|
|
||||||
|
stopped.Coordinator.Poll();
|
||||||
|
stopped.Coordinator.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.ManagerStopped, stopped.Coordinator.State);
|
||||||
|
Assert.Equal(1, stoppedCompletions);
|
||||||
|
|
||||||
|
using ClientHarness disposed = new(clock);
|
||||||
|
int disposedCompletions = 0;
|
||||||
|
disposed.Coordinator.Completed += (_, _) => disposedCompletions++;
|
||||||
|
disposed.Coordinator.Dispose();
|
||||||
|
disposed.Coordinator.Dispose();
|
||||||
|
((INatPunchListener)disposed.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
NatAddressType.External,
|
||||||
|
disposed.IntroductionToken);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Disposed, disposed.Coordinator.State);
|
||||||
|
Assert.Equal(1, disposedCompletions);
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => disposed.Coordinator.Poll());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DirectConnectionRejectionProducesOneTerminalTransition()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness client = new(clock);
|
||||||
|
EventBasedNetListener rejectingEvents = new();
|
||||||
|
rejectingEvents.ConnectionRequestEvent += request => request.Reject();
|
||||||
|
NetManager rejectingHost = new(rejectingEvents);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(rejectingHost.Start(0));
|
||||||
|
int completions = 0;
|
||||||
|
client.Coordinator.Completed += (_, _) => completions++;
|
||||||
|
((INatPunchListener)client.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, rejectingHost.LocalPort),
|
||||||
|
NatAddressType.External,
|
||||||
|
client.IntroductionToken);
|
||||||
|
|
||||||
|
DateTime deadline = DateTime.UtcNow.AddSeconds(2);
|
||||||
|
while (!client.Coordinator.IsCompleted && DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
rejectingHost.PollEvents();
|
||||||
|
client.Coordinator.Poll();
|
||||||
|
await Task.Delay(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State);
|
||||||
|
Assert.Equal(1, completions);
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.HostRejected, client.Coordinator.Outcome!.Kind);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousConnectionOutcomeSource.RemoteHost,
|
||||||
|
client.Coordinator.Outcome.Source);
|
||||||
|
client.Coordinator.Poll();
|
||||||
|
Assert.Equal(1, completions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
rejectingHost.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsynchronizedLiteNetCallbacksAreRejectedAtConstruction()
|
||||||
|
{
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
manager.UnsyncedEvents = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 9_050),
|
||||||
|
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CoordinatorRejectsAManagerCreatedByAnotherRoutingListener()
|
||||||
|
{
|
||||||
|
RendezvousNetListener managerEvents = new();
|
||||||
|
NetManager manager = managerEvents.CreateManager();
|
||||||
|
RendezvousNetListener mismatchedEvents = new();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
|
||||||
|
manager,
|
||||||
|
mismatchedEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 9_050),
|
||||||
|
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishedSessionTimingRemainsValidDuringConcurrentRenewalReads()
|
||||||
|
{
|
||||||
|
DateTimeOffset firstExpiry = new(2030, 1, 1, 0, 1, 0, TimeSpan.Zero);
|
||||||
|
DateTimeOffset secondExpiry = new(2030, 1, 1, 0, 2, 0, TimeSpan.Zero);
|
||||||
|
PublishedSession session = CreateSession(firstExpiry);
|
||||||
|
|
||||||
|
Task writer = Task.Run(() =>
|
||||||
|
{
|
||||||
|
for (int index = 0; index < 10_000; index++)
|
||||||
|
{
|
||||||
|
session.ExpiresAt = index % 2 == 0 ? firstExpiry : secondExpiry;
|
||||||
|
session.LeaseRenewAfterSeconds = index % 2 == 0 ? 10 : 20;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Task reader = Task.Run(() =>
|
||||||
|
{
|
||||||
|
for (int index = 0; index < 10_000; index++)
|
||||||
|
{
|
||||||
|
DateTimeOffset expiry = session.ExpiresAt;
|
||||||
|
int renewAfter = session.LeaseRenewAfterSeconds;
|
||||||
|
Assert.True(expiry == firstExpiry || expiry == secondExpiry);
|
||||||
|
Assert.True(renewAfter is 10 or 20 or 30);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(writer, reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostDoesNotMistakeAnIntroducedAttemptForCancellationWhenItLeavesPolling()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000111"));
|
||||||
|
HostJoinAttempt invitation = new()
|
||||||
|
{
|
||||||
|
AttemptId = attemptId,
|
||||||
|
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000112")),
|
||||||
|
HostPunchCapability = Credential('H'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
NatIntroductionTokenCodec.Encode(attemptId, Credential('T'))),
|
||||||
|
ExpiresAt = clock.UtcNow + TimeSpan.FromSeconds(30),
|
||||||
|
};
|
||||||
|
MutableJoinClient joins = new([invitation]);
|
||||||
|
using ConnectionTicketValidator tickets = new(16, clock);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||||
|
clock,
|
||||||
|
tickets);
|
||||||
|
int completions = 0;
|
||||||
|
host.AttemptCompleted += (_, _) => completions++;
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
Assert.Equal(1, host.PendingAttemptCount);
|
||||||
|
|
||||||
|
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_003),
|
||||||
|
NatAddressType.External,
|
||||||
|
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')));
|
||||||
|
joins.Attempts = [];
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(1, host.PendingAttemptCount);
|
||||||
|
Assert.Equal(0, completions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostCancellationSnapshotRevokesAnAuthorizedTicketAndCompletesOnce()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000131"));
|
||||||
|
string ticket = NatIntroductionTokenCodec.Encode(attemptId, Credential('T'));
|
||||||
|
HostJoinAttempt invitation = CreateHostAttempt(
|
||||||
|
attemptId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000132")),
|
||||||
|
ticket,
|
||||||
|
clock.UtcNow + TimeSpan.FromSeconds(30));
|
||||||
|
MutableJoinClient joins = new([invitation]);
|
||||||
|
using ConnectionTicketValidator tickets = new(16, clock);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||||
|
clock,
|
||||||
|
tickets);
|
||||||
|
List<RendezvousConnectionState> completions = [];
|
||||||
|
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_003),
|
||||||
|
NatAddressType.External,
|
||||||
|
ticket);
|
||||||
|
|
||||||
|
invitation.IsCancelled = true;
|
||||||
|
joins.Attempts = [invitation];
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(0, host.PendingAttemptCount);
|
||||||
|
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Revoked,
|
||||||
|
tickets.Consume(attemptId, ticket));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostAppliesOnlyTheLatestUnpolledSnapshot()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000141"));
|
||||||
|
HostJoinAttempt invitation = CreateHostAttempt(
|
||||||
|
attemptId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000142")),
|
||||||
|
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')),
|
||||||
|
clock.UtcNow + TimeSpan.FromSeconds(30));
|
||||||
|
MutableJoinClient joins = new([invitation]);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||||
|
clock,
|
||||||
|
null);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
joins.Attempts = [];
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(0, host.PendingAttemptCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposingHostDuringRefreshDropsTheLateSnapshot()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
BlockingJoinClient joins = new();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||||
|
clock,
|
||||||
|
null);
|
||||||
|
Task<RendezvousClientResult<int>> refresh = host.RefreshJoinAttemptsAsync();
|
||||||
|
await joins.WaitUntilCalled;
|
||||||
|
|
||||||
|
host.Dispose();
|
||||||
|
joins.Complete([]);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await refresh);
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => host.Poll());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostDeadlinesAreNotDelayedByTheBoundedRetryQueue()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId firstId = new(Guid.Parse("00000000-0000-0000-0000-000000000151"));
|
||||||
|
JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000152"));
|
||||||
|
DateTimeOffset expiresAt = clock.UtcNow + TimeSpan.FromSeconds(1);
|
||||||
|
MutableJoinClient joins = new([
|
||||||
|
CreateHostAttempt(
|
||||||
|
firstId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000153")),
|
||||||
|
NatIntroductionTokenCodec.Encode(firstId, Credential('T')),
|
||||||
|
expiresAt),
|
||||||
|
CreateHostAttempt(
|
||||||
|
secondId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000154")),
|
||||||
|
NatIntroductionTokenCodec.Encode(secondId, Credential('U')),
|
||||||
|
expiresAt),
|
||||||
|
]);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
MaximumAttemptChecksPerPoll = 1,
|
||||||
|
JitterRatio = 0,
|
||||||
|
},
|
||||||
|
clock,
|
||||||
|
null);
|
||||||
|
List<RendezvousConnectionState> completions = [];
|
||||||
|
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(0, host.PendingAttemptCount);
|
||||||
|
Assert.Equal(
|
||||||
|
[RendezvousConnectionState.TimedOut, RendezvousConnectionState.TimedOut],
|
||||||
|
completions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostStopPublishesEveryCompletionBeforeReentrantDisposalCanTearDownState()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId firstId = new(Guid.Parse("00000000-0000-0000-0000-000000000161"));
|
||||||
|
JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000162"));
|
||||||
|
MutableJoinClient joins = new([
|
||||||
|
CreateHostAttempt(
|
||||||
|
firstId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000163")),
|
||||||
|
NatIntroductionTokenCodec.Encode(firstId, Credential('T')),
|
||||||
|
clock.UtcNow + TimeSpan.FromSeconds(30)),
|
||||||
|
CreateHostAttempt(
|
||||||
|
secondId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000164")),
|
||||||
|
NatIntroductionTokenCodec.Encode(secondId, Credential('U')),
|
||||||
|
clock.UtcNow + TimeSpan.FromSeconds(30)),
|
||||||
|
]);
|
||||||
|
RendezvousHostCoordinator? host = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||||
|
clock,
|
||||||
|
null);
|
||||||
|
int completions = 0;
|
||||||
|
host.AttemptCompleted += (_, _) =>
|
||||||
|
{
|
||||||
|
completions++;
|
||||||
|
if (completions == 1)
|
||||||
|
{
|
||||||
|
host.Dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
manager.Stop();
|
||||||
|
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(2, completions);
|
||||||
|
Assert.Equal(0, host.PendingAttemptCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
host?.Dispose();
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostPunchTimeoutUsesItsOwnFakeClockBudget()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000161"));
|
||||||
|
MutableJoinClient joins = new([
|
||||||
|
CreateHostAttempt(
|
||||||
|
attemptId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000162")),
|
||||||
|
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')),
|
||||||
|
clock.UtcNow + TimeSpan.FromSeconds(30)),
|
||||||
|
]);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
MaximumPunchRequests = 20,
|
||||||
|
PunchTimeout = TimeSpan.FromMilliseconds(10),
|
||||||
|
JitterRatio = 0,
|
||||||
|
},
|
||||||
|
clock,
|
||||||
|
null);
|
||||||
|
RendezvousHostAttemptCompletedEventArgs? completion = null;
|
||||||
|
host.AttemptCompleted += (_, value) => completion = value;
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
clock.Advance(TimeSpan.FromMilliseconds(10));
|
||||||
|
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, completion!.Outcome.Kind);
|
||||||
|
Assert.Equal(TimeSpan.FromMilliseconds(10), completion.Outcome.Elapsed);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateJoinAttemptResponse CreateAttempt(DateTimeOffset expiresAt) => new()
|
||||||
|
{
|
||||||
|
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")),
|
||||||
|
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000102")),
|
||||||
|
ClientPunchCapability = Credential('C'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
NatIntroductionTokenCodec.Encode(
|
||||||
|
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000101")),
|
||||||
|
Credential('T'))),
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PublishedSession CreateSession(DateTimeOffset expiresAt) => new(new RegisterSessionResponse
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000121")),
|
||||||
|
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000122")),
|
||||||
|
LeaseToken = "lease-token",
|
||||||
|
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000123")),
|
||||||
|
HostPresenceCapability = Credential('P'),
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
LeaseRenewAfterSeconds = 30,
|
||||||
|
HostPresenceRefreshAfterSeconds = 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
private static HostJoinAttempt CreateHostAttempt(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
MediationHandle mediationHandle,
|
||||||
|
string connectionTicket,
|
||||||
|
DateTimeOffset expiresAt) => new()
|
||||||
|
{
|
||||||
|
AttemptId = attemptId,
|
||||||
|
MediationHandle = mediationHandle,
|
||||||
|
HostPunchCapability = Credential('H'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(connectionTicket),
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
|
||||||
|
|
||||||
|
private sealed class ClientHarness : IDisposable
|
||||||
|
{
|
||||||
|
internal ClientHarness(
|
||||||
|
ManualCoordinatorClock clock,
|
||||||
|
RendezvousCoordinatorOptions? options = null)
|
||||||
|
{
|
||||||
|
NetworkEvents = new();
|
||||||
|
PunchEvents = NetworkEvents.PunchEvents;
|
||||||
|
Manager = NetworkEvents.CreateManager();
|
||||||
|
Assert.True(Manager.Start(0));
|
||||||
|
CreateJoinAttemptResponse attempt = CreateAttempt(clock.UtcNow + TimeSpan.FromSeconds(30));
|
||||||
|
AttemptId = attempt.AttemptId;
|
||||||
|
IntroductionToken = NatIntroductionTokenCodec.Encode(
|
||||||
|
attempt.AttemptId,
|
||||||
|
Credential('T'));
|
||||||
|
Coordinator = new(
|
||||||
|
Manager,
|
||||||
|
NetworkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_001),
|
||||||
|
attempt,
|
||||||
|
options,
|
||||||
|
clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousNetListener NetworkEvents { get; }
|
||||||
|
internal EventBasedNatPunchListener PunchEvents { get; }
|
||||||
|
internal NetManager Manager { get; }
|
||||||
|
internal RendezvousClientCoordinator Coordinator { get; }
|
||||||
|
internal JoinAttemptId AttemptId { get; }
|
||||||
|
internal string IntroductionToken { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Coordinator.Dispose();
|
||||||
|
Manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MutableJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
internal IReadOnlyList<HostJoinAttempt> Attempts { get; set; } = attempts;
|
||||||
|
|
||||||
|
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||||
|
RendezvousClientResult.Success(Attempts));
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousConnectionOutcome outcome,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingJoinClient : IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<bool> _called = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private readonly TaskCompletionSource<IReadOnlyList<HostJoinAttempt>> _result = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
internal Task WaitUntilCalled => _called.Task;
|
||||||
|
|
||||||
|
internal void Complete(IReadOnlyList<HostJoinAttempt> attempts) =>
|
||||||
|
_result.SetResult(attempts);
|
||||||
|
|
||||||
|
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_called.SetResult(true);
|
||||||
|
return RendezvousClientResult.Success(await _result.Task.WaitAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousConnectionOutcome outcome,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManualCoordinatorClock(DateTimeOffset now) :
|
||||||
|
IRendezvousCoordinatorClock,
|
||||||
|
IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; private set; } = now;
|
||||||
|
public TimeSpan Elapsed { get; private set; }
|
||||||
|
|
||||||
|
internal void Advance(TimeSpan amount)
|
||||||
|
{
|
||||||
|
UtcNow += amount;
|
||||||
|
Elapsed += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void AdjustWallClock(TimeSpan amount) => UtcNow += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
using LiteNetLib;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousCoordinatorIntegrationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(8));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "sdk-direct-connect");
|
||||||
|
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService mediatorService = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await mediatorService.StartAsync(timeout.Token);
|
||||||
|
|
||||||
|
RendezvousNetListener hostEvents = new();
|
||||||
|
RendezvousNetListener clientEvents = new();
|
||||||
|
bool gameplayConnectionRequestHandled = false;
|
||||||
|
hostEvents.GameplayEvents.ConnectionRequestEvent += _ =>
|
||||||
|
gameplayConnectionRequestHandled = true;
|
||||||
|
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
|
||||||
|
EventBasedNatPunchListener clientPunch = clientEvents.PunchEvents;
|
||||||
|
NetManager hostManager = hostEvents.CreateManager();
|
||||||
|
NetManager clientManager = clientEvents.CreateManager();
|
||||||
|
string? introductionToken = null;
|
||||||
|
string? hostIntroductionToken = null;
|
||||||
|
clientPunch.NatIntroductionSuccess += (_, _, token) => introductionToken = token;
|
||||||
|
hostPunch.NatIntroductionSuccess += (_, _, token) => hostIntroductionToken = token;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(hostManager.Start(0));
|
||||||
|
Assert.True(clientManager.Start(0));
|
||||||
|
IPEndPoint mediator = Assert.IsType<IPEndPoint>(mediatorService.LocalEndpoint);
|
||||||
|
FakeJoinClient joinClient = new([hostAttempt]);
|
||||||
|
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
|
||||||
|
using ConnectionTicketValidator tickets = new(1_024, clock);
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
hostManager,
|
||||||
|
hostEvents,
|
||||||
|
mediator,
|
||||||
|
new PublishedSession(registration),
|
||||||
|
joinClient,
|
||||||
|
FastOptions(),
|
||||||
|
clock,
|
||||||
|
tickets);
|
||||||
|
using RendezvousClientCoordinator client = new(
|
||||||
|
clientManager,
|
||||||
|
clientEvents,
|
||||||
|
mediator,
|
||||||
|
created,
|
||||||
|
FastOptions(),
|
||||||
|
clock);
|
||||||
|
List<RendezvousHostAttemptCompletedEventArgs> hostCompletions = [];
|
||||||
|
List<RendezvousConnectionCompletedEventArgs> clientCompletions = [];
|
||||||
|
host.AttemptCompleted += (_, completion) => hostCompletions.Add(completion);
|
||||||
|
client.Completed += (_, completion) => clientCompletions.Add(completion);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync(timeout.Token)).IsSuccess);
|
||||||
|
|
||||||
|
while ((!client.IsCompleted || hostCompletions.Count == 0)
|
||||||
|
&& !timeout.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
host.Poll();
|
||||||
|
client.Poll();
|
||||||
|
await Task.Delay(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
client.State == RendezvousConnectionState.Connected,
|
||||||
|
$"Client ended in {client.State}; host pending={host.PendingAttemptCount}; "
|
||||||
|
+ $"host completions={hostCompletions.Count}; introduction={introductionToken is not null}; "
|
||||||
|
+ $"host introduction={hostIntroductionToken is not null}; "
|
||||||
|
+ $"client digest={NatIntroductionTokenCodec.MatchesDigest(introductionToken, created.ConnectionTicketDigest)}; "
|
||||||
|
+ $"host digest={NatIntroductionTokenCodec.MatchesDigest(hostIntroductionToken, hostAttempt.ConnectionTicketDigest)}.");
|
||||||
|
Assert.NotNull(client.ConnectedPeer);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousConnectionState.Connected,
|
||||||
|
Assert.Single(clientCompletions).State);
|
||||||
|
RendezvousHostAttemptCompletedEventArgs hostCompletion = Assert.Single(hostCompletions);
|
||||||
|
Assert.Equal(created.AttemptId, hostCompletion.AttemptId);
|
||||||
|
Assert.Equal(RendezvousConnectionState.Connected, hostCompletion.State);
|
||||||
|
Assert.NotNull(hostCompletion.Peer);
|
||||||
|
Assert.False(gameplayConnectionRequestHandled);
|
||||||
|
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||||
|
introductionToken,
|
||||||
|
out NatIntroductionToken? introduction));
|
||||||
|
Assert.NotNull(introduction);
|
||||||
|
|
||||||
|
EventBasedNetListener replayEvents = new();
|
||||||
|
bool replayConnected = false;
|
||||||
|
replayEvents.PeerConnectedEvent += _ => replayConnected = true;
|
||||||
|
NetManager replayManager = new(replayEvents);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(replayManager.Start(0));
|
||||||
|
replayManager.Connect(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||||
|
DirectConnectionRequestCodec.Encode(
|
||||||
|
created.AttemptId,
|
||||||
|
introduction.ConnectionTicket));
|
||||||
|
DateTime replayDeadline = DateTime.UtcNow.AddSeconds(1);
|
||||||
|
while (DateTime.UtcNow < replayDeadline && !replayConnected)
|
||||||
|
{
|
||||||
|
host.Poll();
|
||||||
|
replayManager.PollEvents();
|
||||||
|
await Task.Delay(2, timeout.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.False(replayConnected);
|
||||||
|
Assert.False(gameplayConnectionRequestHandled);
|
||||||
|
Assert.Single(hostCompletions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
replayManager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
hostManager.Stop();
|
||||||
|
clientManager.Stop();
|
||||||
|
await mediatorService.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostDefersADirectRequestUntilTheMatchingNatIntroductionArrives()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "direct-before-nat");
|
||||||
|
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
ConnectionTicketGrant grant = Assert.IsType<ConnectionTicketGrant>(
|
||||||
|
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
|
||||||
|
RendezvousNetListener hostEvents = new();
|
||||||
|
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
|
||||||
|
EventBasedNetListener clientEvents = new();
|
||||||
|
bool clientConnected = false;
|
||||||
|
clientEvents.PeerConnectedEvent += _ => clientConnected = true;
|
||||||
|
NetManager hostManager = hostEvents.CreateManager();
|
||||||
|
NetManager clientManager = new(clientEvents);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(hostManager.Start(0));
|
||||||
|
Assert.True(clientManager.Start(0));
|
||||||
|
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
|
||||||
|
FakeJoinClient joins = new([hostAttempt]);
|
||||||
|
using ConnectionTicketValidator tickets = new(16, clock);
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
hostManager,
|
||||||
|
hostEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
new PublishedSession(registration),
|
||||||
|
joins,
|
||||||
|
FastOptions(),
|
||||||
|
clock,
|
||||||
|
tickets);
|
||||||
|
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
|
||||||
|
host.AttemptCompleted += (_, completion) => completions.Add(completion);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
bool observedDeferredRequest = false;
|
||||||
|
hostEvents.RendezvousConnectionRequest += _ =>
|
||||||
|
{
|
||||||
|
observedDeferredRequest = host.DeferredRequestCount == 1;
|
||||||
|
((INatPunchListener)hostPunch).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, clientManager.LocalPort),
|
||||||
|
NatAddressType.External,
|
||||||
|
grant.Ticket);
|
||||||
|
};
|
||||||
|
|
||||||
|
clientManager.Connect(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||||
|
DirectConnectionRequestCodec.Encode(created.AttemptId, grant.Ticket));
|
||||||
|
DateTime connectedDeadline = DateTime.UtcNow.AddSeconds(1);
|
||||||
|
while ((!clientConnected || completions.Count == 0)
|
||||||
|
&& DateTime.UtcNow < connectedDeadline)
|
||||||
|
{
|
||||||
|
host.Poll();
|
||||||
|
clientManager.PollEvents();
|
||||||
|
await Task.Delay(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
clientConnected,
|
||||||
|
$"Deferred observed={observedDeferredRequest}; deferred={host.DeferredRequestCount}; "
|
||||||
|
+ $"pending={host.PendingAttemptCount}; completions={completions.Count}.");
|
||||||
|
Assert.True(observedDeferredRequest);
|
||||||
|
Assert.Equal(0, host.DeferredRequestCount);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousConnectionState.Connected,
|
||||||
|
Assert.Single(completions).State);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
hostManager.Stop();
|
||||||
|
clientManager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousCoordinatorOptions FastOptions() => new()
|
||||||
|
{
|
||||||
|
MaximumPunchRequests = 20,
|
||||||
|
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||||
|
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(100),
|
||||||
|
JitterRatio = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class FakeJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||||
|
RendezvousClientResult.Success(attempts));
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousConnectionOutcome outcome,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedCoordinatorClock(DateTimeOffset now) :
|
||||||
|
IRendezvousCoordinatorClock,
|
||||||
|
IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; } = now;
|
||||||
|
public TimeSpan Elapsed => TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousJoinClientTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinIssuanceRetriesTheSameIdempotentPayloadAndCancellationUsesCapability()
|
||||||
|
{
|
||||||
|
CreateJoinAttemptResponse created = CreateAttempt();
|
||||||
|
RecordingHandler handler = new(
|
||||||
|
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
|
||||||
|
JsonResponse(HttpStatusCode.Created, created),
|
||||||
|
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
|
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousJoinClient client = new(
|
||||||
|
http,
|
||||||
|
new RendezvousClientOptions { JitterRatio = 0 },
|
||||||
|
new ImmediateDelay());
|
||||||
|
CreateJoinAttemptRequest request = new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "stable-join-key",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000201")),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
RendezvousClientResult<CreateJoinAttemptResponse> result = await client.CreateAsync(request);
|
||||||
|
RendezvousClientResult<bool> cancelled = await client.CancelAsync(created);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
Assert.True(cancelled.IsSuccess, cancelled.Message);
|
||||||
|
Assert.Equal(handler.Requests[0].Body, handler.Requests[1].Body);
|
||||||
|
Assert.Contains("stable-join-key", handler.Requests[0].Body, StringComparison.Ordinal);
|
||||||
|
RecordedRequest cancellation = handler.Requests[2];
|
||||||
|
Assert.Equal(HttpMethod.Delete, cancellation.Method);
|
||||||
|
Assert.Equal(
|
||||||
|
created.ClientPunchCapability,
|
||||||
|
cancellation.Headers["X-Rendezvous-Client-Punch-Capability"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostInvitationPollingFollowsCursorsWithTheLeaseToken()
|
||||||
|
{
|
||||||
|
HostJoinAttempt first = CreateHostAttempt("00000000-0000-0000-0000-000000000211");
|
||||||
|
HostJoinAttempt second = CreateHostAttempt("00000000-0000-0000-0000-000000000212");
|
||||||
|
RecordingHandler handler = new(
|
||||||
|
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
|
||||||
|
{
|
||||||
|
Items = [first],
|
||||||
|
NextCursor = "next page+cursor",
|
||||||
|
}),
|
||||||
|
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
|
||||||
|
{
|
||||||
|
Items = [second],
|
||||||
|
}));
|
||||||
|
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousJoinClient client = new(http);
|
||||||
|
PublishedSession session = new(new RegisterSessionResponse
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000220")),
|
||||||
|
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000221")),
|
||||||
|
LeaseToken = "lease-secret",
|
||||||
|
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000222")),
|
||||||
|
HostPresenceCapability = Credential('P'),
|
||||||
|
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||||
|
LeaseRenewAfterSeconds = 15,
|
||||||
|
HostPresenceRefreshAfterSeconds = 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
|
||||||
|
await client.BrowseAllForHostAsync(session);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
Assert.Equal([first.AttemptId, second.AttemptId], result.Value!.Select(item => item.AttemptId));
|
||||||
|
Assert.Equal(2, handler.Requests.Count);
|
||||||
|
Assert.All(handler.Requests, request =>
|
||||||
|
Assert.Equal("lease-secret", request.Headers["X-Rendezvous-Lease-Token"]));
|
||||||
|
Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OutcomeReportingUsesTheAttemptCapabilityAndCoarseElapsedBucket()
|
||||||
|
{
|
||||||
|
CreateJoinAttemptResponse attempt = CreateAttempt();
|
||||||
|
RecordingHandler handler = new(JsonResponse(HttpStatusCode.OK, new ReportConnectionOutcomeResponse
|
||||||
|
{
|
||||||
|
Accepted = true,
|
||||||
|
IsDuplicate = false,
|
||||||
|
}));
|
||||||
|
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousJoinClient client = new(http);
|
||||||
|
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.DirectConnectTimedOut,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection,
|
||||||
|
TimeSpan.FromSeconds(6));
|
||||||
|
|
||||||
|
RendezvousClientResult<ReportConnectionOutcomeResponse> result =
|
||||||
|
await client.ReportOutcomeAsync(attempt, outcome);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
RecordedRequest request = Assert.Single(handler.Requests);
|
||||||
|
Assert.Equal(HttpMethod.Post, request.Method);
|
||||||
|
Assert.Equal(
|
||||||
|
attempt.ClientPunchCapability,
|
||||||
|
request.Headers["X-Rendezvous-Client-Punch-Capability"]);
|
||||||
|
Assert.Contains("\"outcome\":\"directConnectTimedOut\"", request.Body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"elapsedBucket\":\"fiveToFifteenSeconds\"", request.Body, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("diagnostic", request.Body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectionStartReturnsATypedServiceOutcomeInsteadOfAnUnboundedFailure()
|
||||||
|
{
|
||||||
|
RecordingHandler handler = new(JsonResponse(HttpStatusCode.NotFound, new ApiError
|
||||||
|
{
|
||||||
|
Code = RendezvousErrorCode.NotFound,
|
||||||
|
Message = "listing unavailable",
|
||||||
|
}));
|
||||||
|
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousJoinClient client = new(http);
|
||||||
|
NetworkEndpoint fallback = new()
|
||||||
|
{
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
Address = "203.0.113.93",
|
||||||
|
Port = 9_063,
|
||||||
|
};
|
||||||
|
|
||||||
|
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
|
||||||
|
new CreateJoinAttemptRequest
|
||||||
|
{
|
||||||
|
IdempotencyKey = "typed-start",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
},
|
||||||
|
fallback);
|
||||||
|
|
||||||
|
Assert.True(result.IsCompleted);
|
||||||
|
Assert.False(result.IsReadyForTraversal);
|
||||||
|
Assert.Null(result.Attempt);
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.DirectoryNotFound, result.Outcome!.Kind);
|
||||||
|
Assert.Equal("203.0.113.93", result.Outcome.DedicatedFallback!.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectionStartReturnsCancelledForAPrecancelledCallerToken()
|
||||||
|
{
|
||||||
|
RecordingHandler handler = new();
|
||||||
|
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousJoinClient client = new(http);
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
cancellation.Cancel();
|
||||||
|
|
||||||
|
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
|
||||||
|
CreateRequest("cancelled-before-send"),
|
||||||
|
new NetworkEndpoint
|
||||||
|
{
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
Address = "203.0.113.90",
|
||||||
|
Port = 7777,
|
||||||
|
},
|
||||||
|
cancellationToken: cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Empty(handler.Requests);
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
|
||||||
|
Assert.True(result.Outcome.HasDedicatedFallback);
|
||||||
|
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectionStartReturnsCancelledWhenCallerStopsASilentRequest()
|
||||||
|
{
|
||||||
|
CancellingHandler handler = new();
|
||||||
|
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousJoinClient client = new(http);
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
Task<RendezvousConnectionStartResult> pending = client.CreateConnectionAttemptAsync(
|
||||||
|
CreateRequest("cancelled-in-flight"),
|
||||||
|
cancellationToken: cancellation.Token);
|
||||||
|
await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await cancellation.CancelAsync();
|
||||||
|
RendezvousConnectionStartResult result = await pending;
|
||||||
|
|
||||||
|
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
|
||||||
|
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateJoinAttemptResponse CreateAttempt() => new()
|
||||||
|
{
|
||||||
|
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")),
|
||||||
|
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000203")),
|
||||||
|
ClientPunchCapability = Credential('C'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
NatIntroductionTokenCodec.Encode(
|
||||||
|
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000202")),
|
||||||
|
Credential('T'))),
|
||||||
|
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static CreateJoinAttemptRequest CreateRequest(string idempotencyKey) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = idempotencyKey,
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static HostJoinAttempt CreateHostAttempt(string id) => new()
|
||||||
|
{
|
||||||
|
AttemptId = new(Guid.Parse(id)),
|
||||||
|
MediationHandle = new(Guid.NewGuid()),
|
||||||
|
HostPunchCapability = Credential('H'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
NatIntroductionTokenCodec.Encode(new JoinAttemptId(Guid.Parse(id)), Credential('T'))),
|
||||||
|
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
|
||||||
|
|
||||||
|
private static HttpResponseMessage JsonResponse<T>(HttpStatusCode status, T value) => new(status)
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options)),
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class RecordingHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||||
|
|
||||||
|
internal List<RecordedRequest> Requests { get; } = [];
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> headers = request.Headers.ToDictionary(
|
||||||
|
static item => item.Key,
|
||||||
|
static item => string.Join(",", item.Value),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
Requests.Add(new(
|
||||||
|
request.Method,
|
||||||
|
request.RequestUri!,
|
||||||
|
headers,
|
||||||
|
request.Content is null
|
||||||
|
? string.Empty
|
||||||
|
: await request.Content.ReadAsStringAsync(cancellationToken)));
|
||||||
|
return _responses.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CancellingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
internal TaskCompletionSource Started { get; } = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_ = request;
|
||||||
|
Started.TrySetResult();
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
throw new InvalidOperationException("The silent request unexpectedly completed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RecordedRequest(
|
||||||
|
HttpMethod Method,
|
||||||
|
Uri Uri,
|
||||||
|
IReadOnlyDictionary<string, string> Headers,
|
||||||
|
string Body);
|
||||||
|
|
||||||
|
private sealed class ImmediateDelay : IRendezvousDelay
|
||||||
|
{
|
||||||
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||||
|
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.ConnectionOutcomes;
|
||||||
|
|
||||||
|
public sealed class ConnectionOutcomeServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ReportRemainsAuthenticatedAfterAttemptExpiryAndCountsOnlyOnce()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||||
|
ConnectionOutcomeMetrics metrics = new();
|
||||||
|
ConnectionOutcomeService service = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
metrics);
|
||||||
|
ReportConnectionOutcomeRequest report = new()
|
||||||
|
{
|
||||||
|
Outcome = ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||||
|
};
|
||||||
|
fixture.Sessions.Clock.Advance(fixture.Sessions.StoreOptions.JoinAttemptLifetime);
|
||||||
|
|
||||||
|
ConnectionOutcomeServiceResult first = service.Report(
|
||||||
|
attempt.AttemptId,
|
||||||
|
attempt.ClientPunchCapability,
|
||||||
|
report);
|
||||||
|
ConnectionOutcomeServiceResult duplicate = service.Report(
|
||||||
|
attempt.AttemptId,
|
||||||
|
attempt.ClientPunchCapability,
|
||||||
|
report);
|
||||||
|
ConnectionOutcomeServiceResult conflict = service.Report(
|
||||||
|
attempt.AttemptId,
|
||||||
|
attempt.ClientPunchCapability,
|
||||||
|
new ReportConnectionOutcomeRequest
|
||||||
|
{
|
||||||
|
Outcome = ConnectionOutcomeKind.Connected,
|
||||||
|
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.False(first.Value!.IsDuplicate);
|
||||||
|
Assert.True(duplicate.Succeeded);
|
||||||
|
Assert.True(duplicate.Value!.IsDuplicate);
|
||||||
|
Assert.Equal(RendezvousErrorCode.ReplayRejected, conflict.Error);
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
metrics.GetCount(
|
||||||
|
ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
ConnectionElapsedBucket.FiveToFifteenSeconds));
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
service.Report(
|
||||||
|
attempt.AttemptId,
|
||||||
|
new string('X', ContractLimits.DerivedCredentialCharacters),
|
||||||
|
report).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ConnectionOutcomeKind.DirectoryNotFound)]
|
||||||
|
[InlineData(ConnectionOutcomeKind.IncompatibleProtocol)]
|
||||||
|
[InlineData(ConnectionOutcomeKind.Unauthorized)]
|
||||||
|
[InlineData(ConnectionOutcomeKind.RateLimited)]
|
||||||
|
public void ReportRejectsOutcomesThatCouldNotHaveAnIssuedAttempt(
|
||||||
|
ConnectionOutcomeKind outcome)
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||||
|
ConnectionOutcomeService service = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
new ConnectionOutcomeMetrics());
|
||||||
|
|
||||||
|
ConnectionOutcomeServiceResult result = service.Report(
|
||||||
|
attempt.AttemptId,
|
||||||
|
attempt.ClientPunchCapability,
|
||||||
|
new ReportConnectionOutcomeRequest
|
||||||
|
{
|
||||||
|
Outcome = outcome,
|
||||||
|
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListingDeletionRemovesRetainedOutcomeAuthorization()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||||
|
ConnectionOutcomeService service = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
new ConnectionOutcomeMetrics());
|
||||||
|
|
||||||
|
Assert.True(fixture.Sessions.Store.RevokeListing(registration.ListingId).Succeeded);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
service.Report(
|
||||||
|
attempt.AttemptId,
|
||||||
|
attempt.ClientPunchCapability,
|
||||||
|
new ReportConnectionOutcomeRequest
|
||||||
|
{
|
||||||
|
Outcome = ConnectionOutcomeKind.Cancelled,
|
||||||
|
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
|
||||||
|
}).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrincipalRevocationRemovesReportAuthorizationAfterAttemptExpiry()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||||
|
ConnectionOutcomeService service = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
new ConnectionOutcomeMetrics());
|
||||||
|
fixture.Sessions.Clock.Advance(fixture.Sessions.StoreOptions.JoinAttemptLifetime);
|
||||||
|
|
||||||
|
Assert.True(fixture.Sessions.Store.RevokePrincipal(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
TimeSpan.FromMinutes(1)).Succeeded);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
service.Report(
|
||||||
|
attempt.AttemptId,
|
||||||
|
attempt.ClientPunchCapability,
|
||||||
|
new ReportConnectionOutcomeRequest
|
||||||
|
{
|
||||||
|
Outcome = ConnectionOutcomeKind.Cancelled,
|
||||||
|
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
|
||||||
|
}).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FrozenV1ReportFieldsAreAcceptedButNormalizedBeforeRetention()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||||
|
ConnectionOutcomeMetrics metrics = new();
|
||||||
|
ConnectionOutcomeService service = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
metrics);
|
||||||
|
#pragma warning disable CS0618 // Deliberately exercises the frozen legacy input surface.
|
||||||
|
ReportConnectionOutcomeRequest legacy = new()
|
||||||
|
{
|
||||||
|
Outcome = ConnectionOutcomeKind.TimedOut,
|
||||||
|
ElapsedMilliseconds = 6_000,
|
||||||
|
DiagnosticCode = "legacy-text-is-discarded",
|
||||||
|
};
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
|
ConnectionOutcomeServiceResult result = service.Report(
|
||||||
|
attempt.AttemptId,
|
||||||
|
attempt.ClientPunchCapability,
|
||||||
|
legacy);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
metrics.GetCount(
|
||||||
|
ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
ConnectionElapsedBucket.FiveToFifteenSeconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,15 @@ namespace FinalFactory.Rendezvous.Tests.Contracts;
|
|||||||
|
|
||||||
public sealed class ContractLimitTests
|
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]
|
[Fact]
|
||||||
public void ByteAndCollectionLimitsAcceptTheBoundaryOnly()
|
public void ByteAndCollectionLimitsAcceptTheBoundaryOnly()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public sealed class ContractSerializationTests
|
|||||||
public static TheoryData<string, Type> GoldenJsonVectors => new()
|
public static TheoryData<string, Type> GoldenJsonVectors => new()
|
||||||
{
|
{
|
||||||
{ "register-session.json", typeof(RegisterSessionRequest) },
|
{ "register-session.json", typeof(RegisterSessionRequest) },
|
||||||
|
{ "register-session-response.json", typeof(RegisterSessionResponse) },
|
||||||
{ "browse-sessions.json", typeof(BrowseSessionsResponse) },
|
{ "browse-sessions.json", typeof(BrowseSessionsResponse) },
|
||||||
{ "create-join-response.json", typeof(CreateJoinAttemptResponse) },
|
{ "create-join-response.json", typeof(CreateJoinAttemptResponse) },
|
||||||
{ "api-error.json", typeof(ApiError) },
|
{ "api-error.json", typeof(ApiError) },
|
||||||
@@ -69,10 +70,10 @@ public sealed class ContractSerializationTests
|
|||||||
public void UnknownEnumNamesAndNumericValuesAreRejected()
|
public void UnknownEnumNamesAndNumericValuesAreRejected()
|
||||||
{
|
{
|
||||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
|
||||||
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}",
|
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedBucket\":\"underOneSecond\"}",
|
||||||
ContractJson.Options));
|
ContractJson.Options));
|
||||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
|
||||||
"{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}",
|
"{\"contractVersion\":1,\"outcome\":99,\"elapsedBucket\":\"underOneSecond\"}",
|
||||||
ContractJson.Options));
|
ContractJson.Options));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ public sealed class ContractSerializationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
|
public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
|
||||||
{
|
{
|
||||||
|
Assert.Equal(9, ContractJson.Options.MaxDepth);
|
||||||
Assert.True(ContractJson.Options.IsReadOnly);
|
Assert.True(ContractJson.Options.IsReadOnly);
|
||||||
Assert.Throws<InvalidOperationException>(() =>
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
ContractJson.Options.WriteIndented = true);
|
ContractJson.Options.WriteIndented = true);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||||
|
|
||||||
|
public sealed class NatPunchRequestTokenCodecTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NatPunchPeerRole.HostPresence)]
|
||||||
|
[InlineData(NatPunchPeerRole.Host)]
|
||||||
|
[InlineData(NatPunchPeerRole.Client)]
|
||||||
|
public void FixedSizeTokensRoundTripBelowLiteNetLibLimit(NatPunchPeerRole role)
|
||||||
|
{
|
||||||
|
MediationHandle handle = new(Guid.NewGuid());
|
||||||
|
const string capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||||
|
|
||||||
|
string encoded = NatPunchRequestTokenCodec.Encode(role, handle, capability);
|
||||||
|
|
||||||
|
Assert.Equal(NatPunchRequestTokenCodec.EncodedLength, encoded.Length);
|
||||||
|
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||||
|
Assert.True(NatPunchRequestTokenCodec.TryDecode(encoded, out NatPunchRequestToken? decoded));
|
||||||
|
Assert.NotNull(decoded);
|
||||||
|
Assert.Equal(role, decoded.Role);
|
||||||
|
Assert.Equal(handle, decoded.MediationHandle);
|
||||||
|
Assert.Equal(capability, decoded.Capability);
|
||||||
|
Assert.DoesNotContain(capability, decoded.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MalformedAndNonCanonicalTokensAreRejected()
|
||||||
|
{
|
||||||
|
string valid = NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Client,
|
||||||
|
new MediationHandle(Guid.Parse("00112233-4455-6677-8899-aabbccddeeff")),
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||||
|
string uppercaseHandle = valid[..6]
|
||||||
|
+ valid.Substring(6, 32).ToUpperInvariant()
|
||||||
|
+ valid[38..];
|
||||||
|
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode(null, out _));
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1], out _));
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode("x" + valid[1..], out _));
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1] + "x", out _));
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode(uppercaseHandle, out _));
|
||||||
|
Assert.Throws<ArgumentException>(() => NatPunchRequestTokenCodec.Encode(
|
||||||
|
(NatPunchPeerRole)99,
|
||||||
|
new MediationHandle(Guid.NewGuid()),
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedWireLengthHasADedicatedLiteNetSafeContractLimit()
|
||||||
|
{
|
||||||
|
Assert.Equal(192, ContractLimits.NatPunchRequestTokenCharacters);
|
||||||
|
Assert.Equal(
|
||||||
|
ContractLimits.NatPunchRequestTokenCharacters,
|
||||||
|
NatPunchRequestTokenCodec.EncodedLength);
|
||||||
|
Assert.True(
|
||||||
|
ContractLimits.NatPunchRequestTokenCharacters
|
||||||
|
<= ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
"/health/live",
|
"/health/live",
|
||||||
"/health/ready",
|
"/health/ready",
|
||||||
"/v1/join-attempts",
|
"/v1/join-attempts",
|
||||||
|
"/v1/join-attempts/{attemptId}",
|
||||||
"/v1/join-attempts/{attemptId}/outcome",
|
"/v1/join-attempts/{attemptId}/outcome",
|
||||||
"/v1/sessions",
|
"/v1/sessions",
|
||||||
"/v1/sessions/{listingId}",
|
"/v1/sessions/{listingId}",
|
||||||
@@ -21,6 +22,7 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
"buildVersion",
|
"buildVersion",
|
||||||
"capacity",
|
"capacity",
|
||||||
"contractVersion",
|
"contractVersion",
|
||||||
|
"dedicatedFallback",
|
||||||
"displayName",
|
"displayName",
|
||||||
"environmentId",
|
"environmentId",
|
||||||
"gameId",
|
"gameId",
|
||||||
@@ -62,7 +64,90 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
Assert.Equal(ExpectedListingProperties, listingProperties);
|
Assert.Equal(ExpectedListingProperties, listingProperties);
|
||||||
Assert.DoesNotContain(listingProperties, static property =>
|
Assert.DoesNotContain(listingProperties, static property =>
|
||||||
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||||
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
||||||
|
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
|
||||||
|
.GetProperty("properties")
|
||||||
|
.GetProperty("dedicatedFallback");
|
||||||
|
JsonElement fallbackReference = Assert.Single(
|
||||||
|
dedicatedFallback.GetProperty("oneOf").EnumerateArray(),
|
||||||
|
static schema => schema.TryGetProperty("$ref", out _));
|
||||||
|
Assert.Equal(
|
||||||
|
"#/components/schemas/NetworkEndpoint",
|
||||||
|
fallbackReference.GetProperty("$ref").GetString());
|
||||||
|
|
||||||
|
JsonElement outcomeReportProperties = schemas.GetProperty("ReportConnectionOutcomeRequest")
|
||||||
|
.GetProperty("properties");
|
||||||
|
Assert.True(outcomeReportProperties.TryGetProperty("elapsedBucket", out _));
|
||||||
|
Assert.True(outcomeReportProperties.TryGetProperty("elapsedMilliseconds", out _));
|
||||||
|
Assert.True(outcomeReportProperties.TryGetProperty("diagnosticCode", out _));
|
||||||
|
string[] outcomeNames = schemas.GetProperty("ConnectionOutcomeKind")
|
||||||
|
.GetProperty("enum")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(static value => value.GetString()!)
|
||||||
|
.ToArray();
|
||||||
|
Assert.Contains("timedOut", outcomeNames);
|
||||||
|
Assert.Contains("staleHost", outcomeNames);
|
||||||
|
Assert.Contains("transportFailed", outcomeNames);
|
||||||
|
Assert.Contains("punchTimedOut", outcomeNames);
|
||||||
|
Assert.Contains("directConnectTimedOut", outcomeNames);
|
||||||
|
Assert.Contains("transportError", outcomeNames);
|
||||||
|
|
||||||
|
JsonElement publisherBearer = root.GetProperty("components")
|
||||||
|
.GetProperty("securitySchemes")
|
||||||
|
.GetProperty("PublisherBearer");
|
||||||
|
Assert.Equal("http", publisherBearer.GetProperty("type").GetString());
|
||||||
|
Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString());
|
||||||
|
JsonElement attemptCapability = root.GetProperty("components")
|
||||||
|
.GetProperty("securitySchemes")
|
||||||
|
.GetProperty("JoinAttemptCapability");
|
||||||
|
Assert.Equal("apiKey", attemptCapability.GetProperty("type").GetString());
|
||||||
|
Assert.Equal(
|
||||||
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
|
attemptCapability.GetProperty("name").GetString());
|
||||||
|
(string Path, string Method)[] publisherOperations =
|
||||||
|
[
|
||||||
|
("/v1/sessions", "post"),
|
||||||
|
("/v1/sessions/{listingId}", "put"),
|
||||||
|
("/v1/sessions/{listingId}", "delete"),
|
||||||
|
("/v1/sessions/{listingId}/renew", "post"),
|
||||||
|
];
|
||||||
|
foreach ((string operationPath, string method) in publisherOperations)
|
||||||
|
{
|
||||||
|
JsonElement security = root.GetProperty("paths")
|
||||||
|
.GetProperty(operationPath)
|
||||||
|
.GetProperty(method)
|
||||||
|
.GetProperty("security");
|
||||||
|
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonElement cancelParameters = root.GetProperty("paths")
|
||||||
|
.GetProperty("/v1/join-attempts/{attemptId}")
|
||||||
|
.GetProperty("delete")
|
||||||
|
.GetProperty("parameters");
|
||||||
|
JsonElement cancelCapability = Assert.Single(cancelParameters.EnumerateArray(), static parameter =>
|
||||||
|
parameter.GetProperty("in").GetString() == "header"
|
||||||
|
&& parameter.GetProperty("name").GetString()
|
||||||
|
== "X-Rendezvous-Client-Punch-Capability");
|
||||||
|
Assert.True(cancelCapability.GetProperty("required").GetBoolean());
|
||||||
|
foreach ((string operationPath, string method) in new[]
|
||||||
|
{
|
||||||
|
("/v1/join-attempts/{attemptId}", "delete"),
|
||||||
|
("/v1/join-attempts/{attemptId}/outcome", "post"),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
JsonElement security = root.GetProperty("paths")
|
||||||
|
.GetProperty(operationPath)
|
||||||
|
.GetProperty(method)
|
||||||
|
.GetProperty("security");
|
||||||
|
Assert.True(security[0].TryGetProperty("JoinAttemptCapability", out _));
|
||||||
|
}
|
||||||
|
JsonElement hostPollParameters = root.GetProperty("paths")
|
||||||
|
.GetProperty("/v1/sessions/{listingId}/join-attempts")
|
||||||
|
.GetProperty("get")
|
||||||
|
.GetProperty("parameters");
|
||||||
|
JsonElement leaseToken = Assert.Single(hostPollParameters.EnumerateArray(), static parameter =>
|
||||||
|
parameter.GetProperty("in").GetString() == "header"
|
||||||
|
&& parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token");
|
||||||
|
Assert.True(leaseToken.GetProperty("required").GetBoolean());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||||
@@ -23,6 +24,24 @@ public sealed class PublicApiCompatibilityTests
|
|||||||
Assert.Equal(expected, snapshot);
|
Assert.Equal(expected, snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClientPublicApiMatchesTheV1Snapshot()
|
||||||
|
{
|
||||||
|
string snapshot = CreateSnapshot(typeof(RendezvousPublisherClient).Assembly);
|
||||||
|
string expected = ContractTestFiles.Read("client-public-api.txt");
|
||||||
|
if (expected == "SNAPSHOT_PENDING"
|
||||||
|
&& Environment.GetEnvironmentVariable("RENDEZVOUS_UPDATE_CONTRACT_SNAPSHOT") == "1")
|
||||||
|
{
|
||||||
|
string snapshotPath = Path.Combine(
|
||||||
|
ContractTestFiles.Directory,
|
||||||
|
"client-public-api.txt");
|
||||||
|
File.WriteAllText(snapshotPath, snapshot + Environment.NewLine);
|
||||||
|
expected = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(expected, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
private static string CreateSnapshot(Assembly assembly)
|
private static string CreateSnapshot(Assembly assembly)
|
||||||
{
|
{
|
||||||
List<string> lines = [];
|
List<string> lines = [];
|
||||||
@@ -58,10 +77,16 @@ public sealed class PublicApiCompatibilityTests
|
|||||||
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||||
.OrderBy(static property => property.Name, StringComparer.Ordinal))
|
.OrderBy(static property => property.Name, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
string accessors = $"{(property.CanRead ? "get;" : string.Empty)}{(property.CanWrite ? "set;" : string.Empty)}";
|
string accessors = $"{(property.GetMethod?.IsPublic == true ? "get;" : string.Empty)}{(property.SetMethod?.IsPublic == true ? "set;" : string.Empty)}";
|
||||||
lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}");
|
lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (EventInfo eventInfo in type.GetEvents(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||||
|
.OrderBy(static eventInfo => eventInfo.Name, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
lines.Add($" EVENT {FormatType(eventInfo.EventHandlerType!)} {eventInfo.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||||
.Where(static method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal))
|
.Where(static method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal))
|
||||||
.OrderBy(static method => method.Name, StringComparer.Ordinal)
|
.OrderBy(static method => method.Name, StringComparer.Ordinal)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||||
|
|
||||||
|
public sealed class TraversalTokenCodecTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IntroductionTokenBindsAttemptAndRedactsTheFixedTicket()
|
||||||
|
{
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000301"));
|
||||||
|
string authenticator = Credential('T');
|
||||||
|
|
||||||
|
string encoded = NatIntroductionTokenCodec.Encode(attemptId, authenticator);
|
||||||
|
|
||||||
|
Assert.Equal(NatIntroductionTokenCodec.EncodedLength, encoded.Length);
|
||||||
|
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||||
|
Assert.True(NatIntroductionTokenCodec.TryDecode(encoded, out NatIntroductionToken? decoded));
|
||||||
|
Assert.NotNull(decoded);
|
||||||
|
Assert.Equal(attemptId, decoded.AttemptId);
|
||||||
|
Assert.Equal(encoded, decoded.ConnectionTicket);
|
||||||
|
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
|
||||||
|
{
|
||||||
|
string valid = NatIntroductionTokenCodec.Encode(
|
||||||
|
new JoinAttemptId(Guid.Parse("abcdef00-0000-0000-0000-000000000302")),
|
||||||
|
Credential('T'));
|
||||||
|
|
||||||
|
Assert.False(NatIntroductionTokenCodec.TryDecode(null, out _));
|
||||||
|
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1], out _));
|
||||||
|
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1] + "!", out _));
|
||||||
|
Assert.False(NatIntroductionTokenCodec.TryDecode(new string('A', 43), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectConnectionRequestRoundTripsFixedBoundedPayloadAndRedactsTicket()
|
||||||
|
{
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000303"));
|
||||||
|
string ticket = Credential('D');
|
||||||
|
|
||||||
|
byte[] encoded = DirectConnectionRequestCodec.Encode(attemptId, ticket);
|
||||||
|
|
||||||
|
Assert.Equal(DirectConnectionRequestCodec.EncodedLength, encoded.Length);
|
||||||
|
Assert.True(DirectConnectionRequestCodec.TryDecode(encoded, out DirectConnectionRequest? decoded));
|
||||||
|
Assert.NotNull(decoded);
|
||||||
|
Assert.Equal(attemptId, decoded.AttemptId);
|
||||||
|
Assert.Equal(ticket, decoded.ConnectionTicket);
|
||||||
|
Assert.DoesNotContain(ticket, decoded.ToString(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
encoded[0] ^= 0xff;
|
||||||
|
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded, out _));
|
||||||
|
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded.AsSpan(1), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||||
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
public sealed class JoinAttemptHttpEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientCreatesHostPollsAndCapabilityCancelsAnAttemptOverHttp()
|
||||||
|
{
|
||||||
|
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||||
|
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(),
|
||||||
|
host.PublisherCredential));
|
||||||
|
Assert.True(host.Capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint presenceFingerprint));
|
||||||
|
Assert.True(host.Store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
presenceFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
CreateJoinAttemptRequest request = new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "http-join-1",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ListingId = session.ListingId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
|
||||||
|
"v1/join-attempts",
|
||||||
|
request,
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
|
||||||
|
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
|
||||||
|
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
|
||||||
|
|
||||||
|
using HttpRequestMessage pollRequest = new(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||||
|
pollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||||
|
using HttpResponseMessage pollResponse = await host.HttpClient.SendAsync(pollRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, pollResponse.StatusCode);
|
||||||
|
BrowseHostJoinAttemptsResponse polled = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||||
|
await pollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||||
|
HostJoinAttempt hostAttempt = Assert.Single(polled.Items);
|
||||||
|
Assert.Equal(created.AttemptId, hostAttempt.AttemptId);
|
||||||
|
Assert.NotEqual(created.ClientPunchCapability, hostAttempt.HostPunchCapability);
|
||||||
|
|
||||||
|
using HttpResponseMessage missingCapability = await host.HttpClient.DeleteAsync(
|
||||||
|
$"v1/join-attempts/{created.AttemptId}");
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, missingCapability.StatusCode);
|
||||||
|
ApiError missingCapabilityError = Assert.IsType<ApiError>(
|
||||||
|
await missingCapability.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, missingCapabilityError.Code);
|
||||||
|
|
||||||
|
using HttpRequestMessage unauthorizedCancel = new(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/join-attempts/{created.AttemptId}");
|
||||||
|
unauthorizedCancel.Headers.Add(
|
||||||
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||||
|
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedCancel);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
|
||||||
|
|
||||||
|
using HttpRequestMessage cancelRequest = new(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/join-attempts/{created.AttemptId}");
|
||||||
|
cancelRequest.Headers.Add(
|
||||||
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
|
created.ClientPunchCapability);
|
||||||
|
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
|
||||||
|
|
||||||
|
using HttpRequestMessage cancelledPollRequest = new(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||||
|
cancelledPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||||
|
using HttpResponseMessage cancelledPollResponse = await host.HttpClient.SendAsync(cancelledPollRequest);
|
||||||
|
BrowseHostJoinAttemptsResponse cancelledPoll = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||||
|
await cancelledPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||||
|
HostJoinAttempt cancelledAttempt = Assert.Single(cancelledPoll.Items);
|
||||||
|
Assert.Equal(created.AttemptId, cancelledAttempt.AttemptId);
|
||||||
|
Assert.True(cancelledAttempt.IsCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OutcomeReportingIsCapabilityAuthenticatedAndIdempotentOverHttp()
|
||||||
|
{
|
||||||
|
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||||
|
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(),
|
||||||
|
host.PublisherCredential));
|
||||||
|
Assert.True(host.Capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint presenceFingerprint));
|
||||||
|
Assert.True(host.Store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
presenceFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
|
||||||
|
"v1/join-attempts",
|
||||||
|
new CreateJoinAttemptRequest
|
||||||
|
{
|
||||||
|
IdempotencyKey = "outcome-report-1",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ListingId = session.ListingId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
},
|
||||||
|
ContractJson.Options);
|
||||||
|
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
|
||||||
|
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
|
||||||
|
ReportConnectionOutcomeRequest report = new()
|
||||||
|
{
|
||||||
|
Outcome = ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
ReportConnectionOutcomeResponse first = await SendOutcomeAsync(
|
||||||
|
host.HttpClient,
|
||||||
|
created,
|
||||||
|
report);
|
||||||
|
ReportConnectionOutcomeResponse duplicate = await SendOutcomeAsync(
|
||||||
|
host.HttpClient,
|
||||||
|
created,
|
||||||
|
report);
|
||||||
|
|
||||||
|
Assert.True(first.Accepted);
|
||||||
|
Assert.False(first.IsDuplicate);
|
||||||
|
Assert.True(duplicate.Accepted);
|
||||||
|
Assert.True(duplicate.IsDuplicate);
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
host.OutcomeMetrics.GetCount(
|
||||||
|
ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
ConnectionElapsedBucket.FiveToFifteenSeconds));
|
||||||
|
|
||||||
|
using HttpRequestMessage conflictRequest = OutcomeRequest(
|
||||||
|
created,
|
||||||
|
new ReportConnectionOutcomeRequest
|
||||||
|
{
|
||||||
|
Outcome = ConnectionOutcomeKind.Connected,
|
||||||
|
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||||
|
});
|
||||||
|
using HttpResponseMessage conflict = await host.HttpClient.SendAsync(conflictRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
|
||||||
|
|
||||||
|
using HttpRequestMessage unauthorizedRequest = OutcomeRequest(created, report);
|
||||||
|
unauthorizedRequest.Headers.Remove("X-Rendezvous-Client-Punch-Capability");
|
||||||
|
unauthorizedRequest.Headers.Add(
|
||||||
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
|
new string('X', ContractLimits.DerivedCredentialCharacters));
|
||||||
|
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(7u, HttpStatusCode.Gone, RendezvousErrorCode.StaleHost)]
|
||||||
|
[InlineData(8u, HttpStatusCode.Conflict, RendezvousErrorCode.IncompatibleProtocol)]
|
||||||
|
public async Task JoinCreationPreservesTypedTerminalErrorsOverHttp(
|
||||||
|
uint protocolVersion,
|
||||||
|
HttpStatusCode expectedStatus,
|
||||||
|
RendezvousErrorCode expectedError)
|
||||||
|
{
|
||||||
|
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||||
|
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(),
|
||||||
|
host.PublisherCredential));
|
||||||
|
|
||||||
|
using HttpResponseMessage response = await host.HttpClient.PostAsJsonAsync(
|
||||||
|
"v1/join-attempts",
|
||||||
|
new CreateJoinAttemptRequest
|
||||||
|
{
|
||||||
|
IdempotencyKey = $"typed-http-error-{protocolVersion}",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ListingId = session.ListingId,
|
||||||
|
ProtocolVersion = protocolVersion,
|
||||||
|
},
|
||||||
|
ContractJson.Options);
|
||||||
|
|
||||||
|
Assert.Equal(expectedStatus, response.StatusCode);
|
||||||
|
ApiError error = Assert.IsType<ApiError>(
|
||||||
|
await response.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
|
||||||
|
Assert.Equal(expectedError, error.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ReportConnectionOutcomeResponse> SendOutcomeAsync(
|
||||||
|
HttpClient client,
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
ReportConnectionOutcomeRequest report)
|
||||||
|
{
|
||||||
|
using HttpRequestMessage request = OutcomeRequest(attempt, report);
|
||||||
|
using HttpResponseMessage response = await client.SendAsync(request);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
return Assert.IsType<ReportConnectionOutcomeResponse>(
|
||||||
|
await response.Content.ReadFromJsonAsync<ReportConnectionOutcomeResponse>(ContractJson.Options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage OutcomeRequest(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
ReportConnectionOutcomeRequest report)
|
||||||
|
{
|
||||||
|
HttpRequestMessage request = new(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"v1/join-attempts/{attempt.AttemptId}/outcome")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(report, options: ContractJson.Options),
|
||||||
|
};
|
||||||
|
request.Headers.Add(
|
||||||
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
|
attempt.ClientPunchCapability);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
return Assert.IsAssignableFrom<T>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CreateRegistration() => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "join-http-host",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "Join HTTP host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class JoinHttpTestHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _application;
|
||||||
|
|
||||||
|
private JoinHttpTestHost(
|
||||||
|
WebApplication application,
|
||||||
|
HttpClient httpClient,
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
EphemeralCapabilityIssuer capabilities,
|
||||||
|
ConnectionOutcomeMetrics outcomeMetrics,
|
||||||
|
string publisherCredential)
|
||||||
|
{
|
||||||
|
_application = application;
|
||||||
|
HttpClient = httpClient;
|
||||||
|
Store = store;
|
||||||
|
Capabilities = capabilities;
|
||||||
|
OutcomeMetrics = outcomeMetrics;
|
||||||
|
PublisherCredential = publisherCredential;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HttpClient HttpClient { get; }
|
||||||
|
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||||
|
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||||
|
internal ConnectionOutcomeMetrics OutcomeMetrics { get; }
|
||||||
|
internal string PublisherCredential { get; }
|
||||||
|
|
||||||
|
internal static async Task<JoinHttpTestHost> StartAsync()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
|
EphemeralStoreOptions stateOptions = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||||
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
|
ProvisioningTestData.CreateOptions(),
|
||||||
|
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||||
|
clock.UtcNow);
|
||||||
|
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||||
|
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||||
|
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
|
options.ThrowOnBadRequest = true);
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||||
|
builder.Services.AddSingleton(provisioning);
|
||||||
|
builder.Services.AddSingleton(provisioning.Policies);
|
||||||
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(clock);
|
||||||
|
builder.Services.AddSingleton(capabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||||
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
|
ConnectionOutcomeMetrics outcomeMetrics = new();
|
||||||
|
builder.Services.AddSingleton(outcomeMetrics);
|
||||||
|
builder.Services.AddSingleton<ConnectionOutcomeService>();
|
||||||
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.MapRendezvousContractEndpoints();
|
||||||
|
await app.StartAsync();
|
||||||
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
|
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
|
return new(
|
||||||
|
app,
|
||||||
|
new HttpClient { BaseAddress = new Uri(address) },
|
||||||
|
store,
|
||||||
|
capabilities,
|
||||||
|
outcomeMetrics,
|
||||||
|
credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
HttpClient.Dispose();
|
||||||
|
await _application.StopAsync();
|
||||||
|
await _application.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
public sealed class JoinAttemptServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreateIsIdempotentAndScopesDistinctRoleCredentials()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "stable-join-key");
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> first = fixture.Service.Create(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
request);
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> replay = fixture.Service.Create(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
request);
|
||||||
|
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.True(replay.Succeeded);
|
||||||
|
Assert.Equal(first.Value!.AttemptId, replay.Value!.AttemptId);
|
||||||
|
Assert.Equal(first.Value.MediationHandle, replay.Value.MediationHandle);
|
||||||
|
Assert.Equal(first.Value.ClientPunchCapability, replay.Value.ClientPunchCapability);
|
||||||
|
Assert.True(ContractValidation.IsCapabilityValid(first.Value.ClientPunchCapability));
|
||||||
|
Assert.InRange(
|
||||||
|
first.Value.ClientPunchCapability.Length,
|
||||||
|
1,
|
||||||
|
ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||||
|
|
||||||
|
HostJoinAttempt host = Assert.Single(fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
10,
|
||||||
|
null).Value!.Items);
|
||||||
|
Assert.NotEqual(host.HostPunchCapability, first.Value.ClientPunchCapability);
|
||||||
|
Assert.DoesNotContain(first.Value.ClientPunchCapability, fixture.Sessions.Store.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameIdempotencyKeyWithDifferentRequestConflicts()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "reused-key");
|
||||||
|
Assert.True(fixture.Service.Create(fixture.ClientSubject, request).Succeeded);
|
||||||
|
|
||||||
|
request.ListingId = other.ListingId;
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> conflict = fixture.Service.Create(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
request);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousErrorCode.Conflict, conflict.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreationRejectsStaleIncompatibleAndCrossTenantListings()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.StaleHost,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
|
||||||
|
|
||||||
|
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest incompatible = fixture.Request(active.ListingId);
|
||||||
|
incompatible.ProtocolVersion = 8;
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, incompatible).Error);
|
||||||
|
|
||||||
|
CreateJoinAttemptRequest otherTenant = fixture.Request(active.ListingId);
|
||||||
|
otherTenant.GameId = new("other-game");
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListingProtocolMismatchRemainsDistinctWhenTheRequestedProtocolIsAllowed()
|
||||||
|
{
|
||||||
|
GamePolicyOptions policy = ProvisioningTestData.CreatePolicy();
|
||||||
|
policy.ProtocolVersions.Add(8);
|
||||||
|
using JoinAttemptFixture fixture = new(joinPolicy: policy);
|
||||||
|
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest request = fixture.Request(active.ListingId);
|
||||||
|
request.ProtocolVersion = 8;
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, request).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IssuedAttemptCarriesTheHostsDedicatedFallbackCandidate()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
RegisterSessionRequest registrationRequest = fixture.Sessions.Request();
|
||||||
|
registrationRequest.DedicatedFallback = new()
|
||||||
|
{
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
Address = "203.0.113.91",
|
||||||
|
Port = 9_061,
|
||||||
|
};
|
||||||
|
RegisterSessionResponse registration = fixture.Sessions.Register(registrationRequest);
|
||||||
|
Assert.True(fixture.Sessions.BindPresence(registration).Succeeded);
|
||||||
|
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
|
||||||
|
Assert.Equal("203.0.113.91", created.DedicatedFallback!.Address);
|
||||||
|
Assert.Equal(9_061, created.DedicatedFallback.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
fixture.Create(registration.ListingId);
|
||||||
|
fixture.Create(registration.ListingId);
|
||||||
|
fixture.Create(registration.ListingId);
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> first = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
1,
|
||||||
|
null);
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.Single(first.Value!.Items);
|
||||||
|
Assert.NotNull(first.Value.NextCursor);
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> second = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
1,
|
||||||
|
first.Value.NextCursor);
|
||||||
|
Assert.True(second.Succeeded);
|
||||||
|
Assert.NotEqual(first.Value.Items[0].AttemptId, second.Value!.Items[0].AttemptId);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
1,
|
||||||
|
null).Error);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.InvalidRequest,
|
||||||
|
fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
1,
|
||||||
|
first.Value.NextCursor + "x").Error);
|
||||||
|
|
||||||
|
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.InvalidRequest,
|
||||||
|
fixture.Service.BrowseForHost(
|
||||||
|
other.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
other.LeaseToken,
|
||||||
|
1,
|
||||||
|
first.Value.NextCursor).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationRequiresTheAttemptsClientCapabilityAndRevokesState()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Error);
|
||||||
|
Assert.True(fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
created.ClientPunchCapability).Succeeded);
|
||||||
|
Assert.True(fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
created.ClientPunchCapability).Succeeded);
|
||||||
|
HostJoinAttempt cancelled = Assert.Single(fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
10,
|
||||||
|
null).Value!.Items);
|
||||||
|
Assert.Equal(created.AttemptId, cancelled.AttemptId);
|
||||||
|
Assert.True(cancelled.IsCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationAfterIntroductionRevokesTicketIssuanceAndConsumption()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
JoinAttemptServiceResult<ConnectionTicketGrant> issued =
|
||||||
|
fixture.Service.IssueConnectionTicket(introduction.Attempt);
|
||||||
|
Assert.True(issued.Succeeded);
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
issued.Value!.Ticket,
|
||||||
|
out SecretFingerprint ticketFingerprint));
|
||||||
|
|
||||||
|
Assert.True(fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
created.ClientPunchCapability).Succeeded);
|
||||||
|
|
||||||
|
StoredJoinAttempt cancelled = fixture.GetAttempt(registration, created.AttemptId);
|
||||||
|
Assert.True(cancelled.IsCancelled);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.Conflict,
|
||||||
|
fixture.Service.IssueConnectionTicket(cancelled).Error);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.Conflict,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
created.AttemptId,
|
||||||
|
ticketFingerprint)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoleAndAttemptCapabilitiesCannotCrossWireConcurrentAttempts()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
||||||
|
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
||||||
|
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
second.ClientPunchCapability,
|
||||||
|
out SecretFingerprint secondClientFingerprint));
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
first.ClientPunchCapability,
|
||||||
|
out SecretFingerprint firstClientFingerprint));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
firstStored.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
secondClientFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "198.51.100.20", 42_000),
|
||||||
|
null)).Code);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
firstStored.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
firstClientFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
|
||||||
|
null)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectionTicketIsDistinctExpiringAndAtomicallySingleUse()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
JoinAttemptServiceResult<ConnectionTicketGrant> issued = fixture.Service.IssueConnectionTicket(
|
||||||
|
introduction.Attempt);
|
||||||
|
Assert.True(issued.Succeeded);
|
||||||
|
Assert.True(ContractValidation.IsConnectionTicketValid(issued.Value!.Ticket));
|
||||||
|
Assert.NotEqual(created.ClientPunchCapability, issued.Value.Ticket);
|
||||||
|
Assert.DoesNotContain(issued.Value.Ticket, issued.Value.ToString(), StringComparison.Ordinal);
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
issued.Value.Ticket,
|
||||||
|
out SecretFingerprint ticketFingerprint));
|
||||||
|
ConsumeConnectionTicketCommand command = new(created.AttemptId, ticketFingerprint);
|
||||||
|
using ManualResetEventSlim start = new(false);
|
||||||
|
|
||||||
|
Task<StoreResult<bool>> left = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
||||||
|
});
|
||||||
|
Task<StoreResult<bool>> right = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
||||||
|
});
|
||||||
|
start.Set();
|
||||||
|
StoreResult<bool>[] results = await Task.WhenAll(left, right);
|
||||||
|
|
||||||
|
Assert.Single(results, static result => result.Succeeded);
|
||||||
|
Assert.Single(results, static result => result.Code == StoreResultCode.ReplayRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TicketRejectsAlteredCrossAttemptPreIntroductionAndExpiry()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new()
|
||||||
|
{
|
||||||
|
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
};
|
||||||
|
using JoinAttemptFixture fixture = new(options);
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
||||||
|
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
||||||
|
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
||||||
|
StoredJoinAttempt secondStored = fixture.GetAttempt(registration, second.AttemptId);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.Conflict,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
first.AttemptId,
|
||||||
|
firstStored.ConnectionTicketFingerprint)).Code);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
second.AttemptId,
|
||||||
|
firstStored.ConnectionTicketFingerprint)).Code);
|
||||||
|
|
||||||
|
fixture.Introduce(registration, first);
|
||||||
|
fixture.Sessions.Clock.Advance(options.ConnectionTicketLifetime);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.Expired,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
first.AttemptId,
|
||||||
|
firstStored.ConnectionTicketFingerprint)).Code);
|
||||||
|
Assert.False(secondStored.ConnectionTicketConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TicketWindowBeginsAtIntroductionAndNeverOutlivesTheAttempt()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
ConnectionTicketGrant ticket = Assert.IsType<ConnectionTicketGrant>(
|
||||||
|
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
|
||||||
|
|
||||||
|
Assert.Equal(created.ExpiresAt, ticket.ExpiresAt);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(15), ticket.ExpiresAt - fixture.Sessions.Clock.UtcNow);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
introduction.Attempt.CapabilityDerivationSalt,
|
||||||
|
introduction.Attempt.ToString(),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Sessions;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptFixture : IDisposable
|
||||||
|
{
|
||||||
|
private int _sequence;
|
||||||
|
|
||||||
|
public JoinAttemptFixture(
|
||||||
|
EphemeralStoreOptions? options = null,
|
||||||
|
GamePolicyOptions? joinPolicy = null)
|
||||||
|
{
|
||||||
|
Sessions = new(options);
|
||||||
|
Cursors = new();
|
||||||
|
GamePolicyRegistry policies = GamePolicyRegistry.Create([
|
||||||
|
joinPolicy ?? ProvisioningTestData.CreatePolicy(),
|
||||||
|
]);
|
||||||
|
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
|
||||||
|
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionLeaseFixture Sessions { get; }
|
||||||
|
public JoinAttemptCursorCodec Cursors { get; }
|
||||||
|
public JoinAttemptService Service { get; }
|
||||||
|
public string ClientSubject { get; }
|
||||||
|
|
||||||
|
public (RegisterSessionResponse Registration, StoredListing Listing) CreateHost(bool bindPresence = true)
|
||||||
|
{
|
||||||
|
RegisterSessionResponse registration = Sessions.Register();
|
||||||
|
if (bindPresence)
|
||||||
|
{
|
||||||
|
Assert.True(Sessions.BindPresence(registration).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing listing = Sessions.Store.GetListing(registration.ListingId, false).Value!;
|
||||||
|
return (registration, listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreateJoinAttemptRequest Request(
|
||||||
|
SessionListingId listingId,
|
||||||
|
string? idempotencyKey = null) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = idempotencyKey ?? $"join-{Interlocked.Increment(ref _sequence)}",
|
||||||
|
GameId = Sessions.Scope.GameId,
|
||||||
|
EnvironmentId = Sessions.Scope.EnvironmentId,
|
||||||
|
ListingId = listingId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
public CreateJoinAttemptResponse Create(
|
||||||
|
SessionListingId listingId,
|
||||||
|
string? idempotencyKey = null)
|
||||||
|
{
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = Service.Create(
|
||||||
|
ClientSubject,
|
||||||
|
Request(listingId, idempotencyKey));
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
return Assert.IsType<CreateJoinAttemptResponse>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredJoinAttempt GetAttempt(
|
||||||
|
RegisterSessionResponse registration,
|
||||||
|
JoinAttemptId attemptId)
|
||||||
|
{
|
||||||
|
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||||
|
registration.LeaseToken,
|
||||||
|
out SecretFingerprint leaseFingerprint));
|
||||||
|
IReadOnlyList<StoredJoinAttempt> attempts = Sessions.Store.BrowseHostJoinAttempts(new(
|
||||||
|
registration.ListingId,
|
||||||
|
leaseFingerprint,
|
||||||
|
ContractLimits.BrowserPageMaxItems)).Value!;
|
||||||
|
return attempts.Single(attempt => attempt.AttemptId == attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IntroductionEndpoints Introduce(
|
||||||
|
RegisterSessionResponse registration,
|
||||||
|
CreateJoinAttemptResponse created)
|
||||||
|
{
|
||||||
|
StoredJoinAttempt attempt = GetAttempt(registration, created.AttemptId);
|
||||||
|
HostJoinAttempt host = Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||||
|
host.HostPunchCapability,
|
||||||
|
out SecretFingerprint hostFingerprint));
|
||||||
|
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||||
|
created.ClientPunchCapability,
|
||||||
|
out SecretFingerprint clientFingerprint));
|
||||||
|
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
hostFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
clientFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "198.51.100.40", 42_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
StoreResult<IntroductionEndpoints> introduced = Sessions.Store.ConsumeIntroduction(
|
||||||
|
attempt.MediationHandle);
|
||||||
|
Assert.True(introduced.Succeeded);
|
||||||
|
return introduced.Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Cursors.Dispose();
|
||||||
|
Sessions.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,15 @@ namespace FinalFactory.Rendezvous.Tests.Provisioning;
|
|||||||
|
|
||||||
public sealed class PrincipalCredentialTests
|
public sealed class PrincipalCredentialTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Base64UrlDecoderRejectsNonCanonicalTrailingBits()
|
||||||
|
{
|
||||||
|
Assert.True(Base64Url.TryDecode("AA", out byte[] canonical));
|
||||||
|
Assert.Equal(new byte[] { 0 }, canonical);
|
||||||
|
Assert.False(Base64Url.TryDecode("AB", out byte[] nonCanonical));
|
||||||
|
Assert.Empty(nonCanonical);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
|
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Server;
|
||||||
|
|
||||||
|
public sealed class NatMediationProcessorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AuthenticatedHostPresenceUsesTheObservedGameplaySocket()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost(bindPresence: false);
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
string token = NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.HostPresence,
|
||||||
|
registration.HostPresenceHandle,
|
||||||
|
registration.HostPresenceCapability);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.HostPresenceAccepted,
|
||||||
|
processor.ProcessRequest(
|
||||||
|
Endpoint("192.168.1.50", 40_000),
|
||||||
|
Endpoint("203.0.113.77", 51_234),
|
||||||
|
token,
|
||||||
|
sink));
|
||||||
|
Assert.Equal(registration.ListingId, Assert.Single(fixture.Sessions.Browse()).Definition.ListingId);
|
||||||
|
Assert.Empty(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "same-nat");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.WaitingForPeer,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Introduced,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
|
||||||
|
|
||||||
|
NatIntroductionPlan plan = Assert.Single(sink.Plans);
|
||||||
|
Assert.Equal(Endpoint("192.168.1.10", 41_000), plan.HostLocal);
|
||||||
|
Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal);
|
||||||
|
Assert.Equal(Endpoint("203.0.113.20", 51_000), plan.HostPublic);
|
||||||
|
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
|
||||||
|
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||||
|
plan.IntroductionToken,
|
||||||
|
out NatIntroductionToken? introduction));
|
||||||
|
Assert.NotNull(introduction);
|
||||||
|
Assert.Equal(attempt.AttemptId, introduction.AttemptId);
|
||||||
|
Assert.Equal(43, introduction.ConnectionTicket.Length);
|
||||||
|
Assert.DoesNotContain(introduction.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
out SecretFingerprint ticketFingerprint));
|
||||||
|
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
attempt.AttemptId,
|
||||||
|
ticketFingerprint)).Succeeded);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Duplicate,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
|
||||||
|
Assert.Single(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "different-nats");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
_ = Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("8.8.8.8", 42_000), Endpoint("198.51.100.40", 52_000));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Introduced,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
|
||||||
|
NatIntroductionPlan plan = Assert.Single(sink.Plans);
|
||||||
|
Assert.Equal(plan.HostPublic, plan.HostLocal);
|
||||||
|
Assert.Equal(plan.ClientPublic, plan.ClientLocal);
|
||||||
|
Assert.NotEqual(IPAddress.Parse("8.8.8.8"), plan.ClientLocal.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoleAndEndpointSubstitutionAreRejectedWithoutChangingTheFirstBinding()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "substitution");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
string crossRole = NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
attempt.Handle,
|
||||||
|
attempt.ClientCapability);
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
processor.ProcessRequest(
|
||||||
|
Endpoint("192.168.1.10", 41_000),
|
||||||
|
Endpoint("203.0.113.20", 51_000),
|
||||||
|
crossRole,
|
||||||
|
sink));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.WaitingForPeer,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Rejected,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.99", 41_999), Endpoint("203.0.113.99", 51_999)));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Introduced,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.2.10", 42_000), Endpoint("198.51.100.40", 52_000)));
|
||||||
|
|
||||||
|
Assert.Equal(Endpoint("203.0.113.20", 51_000), Assert.Single(sink.Plans).HostPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConcurrentAttemptsForOneSessionNeverCrossWire()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials first = CreateAttempt(fixture, registration, "parallel-1");
|
||||||
|
AttemptCredentials second = CreateAttempt(fixture, registration, "parallel-2");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
_ = Process(processor, sink, first, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("10.0.0.10", 41_001), Endpoint("203.0.113.10", 51_001));
|
||||||
|
_ = Process(processor, sink, second, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("10.0.0.20", 41_002), Endpoint("203.0.113.20", 51_002));
|
||||||
|
_ = Process(processor, sink, second, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("10.0.0.21", 42_002), Endpoint("198.51.100.20", 52_002));
|
||||||
|
_ = Process(processor, sink, first, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("10.0.0.11", 42_001), Endpoint("198.51.100.10", 52_001));
|
||||||
|
|
||||||
|
Assert.Equal(2, sink.Plans.Count);
|
||||||
|
Assert.Contains(sink.Plans, plan =>
|
||||||
|
plan.HostPublic.Equals(Endpoint("203.0.113.10", 51_001))
|
||||||
|
&& plan.ClientPublic.Equals(Endpoint("198.51.100.10", 52_001)));
|
||||||
|
Assert.Contains(sink.Plans, plan =>
|
||||||
|
plan.HostPublic.Equals(Endpoint("203.0.113.20", 51_002))
|
||||||
|
&& plan.ClientPublic.Equals(Endpoint("198.51.100.20", 52_002)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentDuplicateCompletionEmitsExactlyOneIntroduction()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "completion-race");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
ConcurrentIntroductionSink sink = new();
|
||||||
|
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
|
||||||
|
using Barrier barrier = new(2);
|
||||||
|
|
||||||
|
Task<NatMediationResult>[] completions = Enumerable.Range(0, 2)
|
||||||
|
.Select(_ => Task.Run(() =>
|
||||||
|
{
|
||||||
|
barrier.SignalAndWait();
|
||||||
|
return Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000));
|
||||||
|
}))
|
||||||
|
.ToArray();
|
||||||
|
NatMediationResult[] results = await Task.WhenAll(completions);
|
||||||
|
|
||||||
|
Assert.Single(results, result => result == NatMediationResult.Introduced);
|
||||||
|
Assert.Single(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancellationAfterIntroductionCreatesAHostRevocationTombstone()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "cancel-race");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
using BlockingIntroductionSink sink = new();
|
||||||
|
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
|
||||||
|
Task<NatMediationResult> completion = Task.Run(() => Process(
|
||||||
|
processor,
|
||||||
|
sink,
|
||||||
|
attempt,
|
||||||
|
NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000),
|
||||||
|
Endpoint("198.51.100.40", 52_000)));
|
||||||
|
Assert.True(sink.WaitUntilEntered(TimeSpan.FromSeconds(2)));
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<bool> cancelled = fixture.Service.Cancel(
|
||||||
|
attempt.AttemptId,
|
||||||
|
attempt.ClientCapability);
|
||||||
|
|
||||||
|
Assert.True(cancelled.Succeeded);
|
||||||
|
sink.Release();
|
||||||
|
Assert.Equal(NatMediationResult.Introduced, await completion);
|
||||||
|
HostJoinAttempt cancelledAttempt = Assert.Single(fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items);
|
||||||
|
Assert.True(cancelledAttempt.IsCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateFloodAmortizesGlobalExpiryMaintenance()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "maintenance-budget");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
long before = fixture.Sessions.Store.MaintenanceSweepCount;
|
||||||
|
|
||||||
|
for (int index = 0; index < 256; index++)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.WaitingForPeer,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.InRange(fixture.Sessions.Store.MaintenanceSweepCount - before, 0, 1);
|
||||||
|
Assert.Empty(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingStaleCancelledAndMalformedRequestsNeverIntroduce()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials stale = CreateAttempt(fixture, registration, "stale");
|
||||||
|
AttemptCredentials cancelled = CreateAttempt(fixture, registration, "cancelled");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.WaitingForPeer,
|
||||||
|
Process(processor, sink, stale, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000)));
|
||||||
|
Assert.True(fixture.Service.Cancel(cancelled.AttemptId, cancelled.ClientCapability).Succeeded);
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
Process(processor, sink, cancelled, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.12", 42_001), Endpoint("198.51.100.41", 52_001)));
|
||||||
|
|
||||||
|
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(21));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
Process(processor, sink, stale, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
processor.ProcessRequest(
|
||||||
|
Endpoint("192.168.1.10", 41_000),
|
||||||
|
Endpoint("203.0.113.20", 51_000),
|
||||||
|
"malformed",
|
||||||
|
sink));
|
||||||
|
Assert.Empty(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddressFamiliesMustMatchAndOnlyGlobalIpv6SourcesAreAccepted()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials mismatch = CreateAttempt(fixture, registration, "family-mismatch");
|
||||||
|
AttemptCredentials ipv6 = CreateAttempt(fixture, registration, "ipv6");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
byte[] shortFrozenIpv6 = RendezvousUdpCodec.Encode(new PresenceDatagram
|
||||||
|
{
|
||||||
|
MessageType = UdpPresenceMessageType.ClientPresence,
|
||||||
|
MediationHandle = ipv6.Handle,
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv6,
|
||||||
|
LocalAddress = "fd00::11",
|
||||||
|
LocalPort = 42_000,
|
||||||
|
Capability = ipv6.ClientCapability,
|
||||||
|
});
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
processor.ProcessDatagram(
|
||||||
|
shortFrozenIpv6,
|
||||||
|
Endpoint("2606:4700:4700::1001", 52_000),
|
||||||
|
sink));
|
||||||
|
|
||||||
|
_ = Process(processor, sink, mismatch, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Rejected,
|
||||||
|
Process(processor, sink, mismatch, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1111", 52_000)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
Process(processor, sink, ipv6, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("fd00::10", 41_000), Endpoint("2001:db8::10", 51_000)));
|
||||||
|
_ = Process(processor, sink, ipv6, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("fd00::10", 41_000), Endpoint("2606:4700:4700::1000", 51_000));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Introduced,
|
||||||
|
Process(processor, sink, ipv6, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1001", 52_000)));
|
||||||
|
Assert.Single(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NatMediationProcessor CreateProcessor(JoinAttemptFixture fixture) => new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
|
||||||
|
private static AttemptCredentials CreateAttempt(
|
||||||
|
JoinAttemptFixture fixture,
|
||||||
|
RegisterSessionResponse registration,
|
||||||
|
string idempotencyKey)
|
||||||
|
{
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, idempotencyKey);
|
||||||
|
HostJoinAttempt host = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
return new(
|
||||||
|
created.AttemptId,
|
||||||
|
created.MediationHandle,
|
||||||
|
host.HostPunchCapability,
|
||||||
|
created.ClientPunchCapability);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NatMediationResult Process(
|
||||||
|
NatMediationProcessor processor,
|
||||||
|
INatIntroductionSink sink,
|
||||||
|
AttemptCredentials attempt,
|
||||||
|
NatPunchPeerRole role,
|
||||||
|
IPEndPoint local,
|
||||||
|
IPEndPoint observed) => processor.ProcessRequest(
|
||||||
|
local,
|
||||||
|
observed,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
role,
|
||||||
|
attempt.Handle,
|
||||||
|
role == NatPunchPeerRole.Client
|
||||||
|
? attempt.ClientCapability
|
||||||
|
: attempt.HostCapability),
|
||||||
|
sink);
|
||||||
|
|
||||||
|
private static IPEndPoint Endpoint(string address, int port) =>
|
||||||
|
new(IPAddress.Parse(address), port);
|
||||||
|
|
||||||
|
private sealed record AttemptCredentials(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
MediationHandle Handle,
|
||||||
|
string HostCapability,
|
||||||
|
string ClientCapability);
|
||||||
|
|
||||||
|
private sealed class CaptureIntroductionSink : INatIntroductionSink
|
||||||
|
{
|
||||||
|
public List<NatIntroductionPlan> Plans { get; } = [];
|
||||||
|
|
||||||
|
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ConcurrentIntroductionSink : INatIntroductionSink
|
||||||
|
{
|
||||||
|
public ConcurrentBag<NatIntroductionPlan> Plans { get; } = [];
|
||||||
|
|
||||||
|
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingIntroductionSink : INatIntroductionSink, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ManualResetEventSlim _entered = new();
|
||||||
|
private readonly ManualResetEventSlim _release = new();
|
||||||
|
|
||||||
|
public void Introduce(NatIntroductionPlan plan)
|
||||||
|
{
|
||||||
|
_entered.Set();
|
||||||
|
_release.Wait(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool WaitUntilEntered(TimeSpan timeout) => _entered.Wait(timeout);
|
||||||
|
public void Release() => _release.Set();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_entered.Dispose();
|
||||||
|
_release.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
using FinalFactory.Rendezvous.Server.Transport;
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
using LiteNetLib;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -8,17 +12,24 @@ namespace FinalFactory.Rendezvous.Tests.Server;
|
|||||||
public sealed class UdpMediatorServiceTests
|
public sealed class UdpMediatorServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
|
public async Task ServiceBindsAnEphemeralLiteNetLibPortAndStopsCleanly()
|
||||||
{
|
{
|
||||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
UdpMediatorOptions options = new()
|
using JoinAttemptFixture fixture = new();
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
{
|
{
|
||||||
ListenAddress = IPAddress.Loopback.ToString(),
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
Port = 0,
|
Port = 0,
|
||||||
};
|
MaxDatagramsPerPoll = 8,
|
||||||
using UdpMediatorService service = new(
|
PollIntervalMilliseconds = 1,
|
||||||
Options.Create(options),
|
}),
|
||||||
NullLogger<UdpMediatorService>.Instance);
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
|
||||||
await service.StartAsync(timeout.Token);
|
await service.StartAsync(timeout.Token);
|
||||||
|
|
||||||
@@ -26,9 +37,307 @@ public sealed class UdpMediatorServiceTests
|
|||||||
Assert.NotNull(boundEndpoint);
|
Assert.NotNull(boundEndpoint);
|
||||||
Assert.Equal(IPAddress.Loopback, boundEndpoint.Address);
|
Assert.Equal(IPAddress.Loopback, boundEndpoint.Address);
|
||||||
Assert.InRange(boundEndpoint.Port, 1, 65_535);
|
Assert.InRange(boundEndpoint.Port, 1, 65_535);
|
||||||
|
Assert.Null(service.LocalIpv6Endpoint);
|
||||||
|
|
||||||
await service.StopAsync(timeout.Token);
|
await service.StopAsync(timeout.Token);
|
||||||
|
|
||||||
Assert.Null(service.LocalEndpoint);
|
Assert.Null(service.LocalEndpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OptionalIpv6BindingNeverWidensTheRequiredIpv4Binding()
|
||||||
|
{
|
||||||
|
if (!Socket.OSSupportsIPv6)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Ipv6ListenAddress = IPAddress.IPv6Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
|
||||||
|
Assert.Equal(IPAddress.Loopback, service.LocalEndpoint!.Address);
|
||||||
|
Assert.Equal(IPAddress.IPv6Loopback, service.LocalIpv6Endpoint!.Address);
|
||||||
|
Assert.Equal(service.LocalEndpoint.Port, service.LocalIpv6Endpoint.Port);
|
||||||
|
IPAddress? otherIpv4 = Dns.GetHostAddresses(Dns.GetHostName())
|
||||||
|
.FirstOrDefault(address =>
|
||||||
|
address.AddressFamily == AddressFamily.InterNetwork
|
||||||
|
&& !IPAddress.IsLoopback(address));
|
||||||
|
if (otherIpv4 is not null)
|
||||||
|
{
|
||||||
|
using UdpClient scopeProbe = new(new IPEndPoint(otherIpv4, service.LocalEndpoint.Port));
|
||||||
|
Assert.Equal(otherIpv4, ((IPEndPoint)scopeProbe.Client.LocalEndPoint!).Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.StopAsync(timeout.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NativeLiteNetLibRequestsIntroduceTheAuthorizedPair()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "native-litenet");
|
||||||
|
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
MaxDatagramsPerPoll = 8,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
|
||||||
|
EventBasedNetListener hostListener = new();
|
||||||
|
EventBasedNetListener clientListener = new();
|
||||||
|
NetManager host = new(hostListener) { NatPunchEnabled = true };
|
||||||
|
NetManager client = new(clientListener) { NatPunchEnabled = true };
|
||||||
|
EventBasedNatPunchListener hostPunch = new();
|
||||||
|
EventBasedNatPunchListener clientPunch = new();
|
||||||
|
List<string> hostTickets = [];
|
||||||
|
List<string> clientTickets = [];
|
||||||
|
hostPunch.NatIntroductionSuccess += (_, _, ticket) => hostTickets.Add(ticket);
|
||||||
|
clientPunch.NatIntroductionSuccess += (_, _, ticket) => clientTickets.Add(ticket);
|
||||||
|
host.NatPunchModule.Init(hostPunch);
|
||||||
|
client.NatPunchModule.Init(clientPunch);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(host.Start(0));
|
||||||
|
Assert.True(client.Start(0));
|
||||||
|
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||||
|
host.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
created.MediationHandle,
|
||||||
|
hostAttempt.HostPunchCapability));
|
||||||
|
client.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Client,
|
||||||
|
created.MediationHandle,
|
||||||
|
created.ClientPunchCapability));
|
||||||
|
|
||||||
|
while ((hostTickets.Count == 0 || clientTickets.Count == 0)
|
||||||
|
&& !timeout.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
host.PollEvents();
|
||||||
|
host.NatPunchModule.PollEvents();
|
||||||
|
client.PollEvents();
|
||||||
|
client.NatPunchModule.PollEvents();
|
||||||
|
await Task.Delay(5, timeout.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal));
|
||||||
|
string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal));
|
||||||
|
Assert.Equal(hostTicket, clientTicket);
|
||||||
|
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||||
|
hostTicket,
|
||||||
|
out NatIntroductionToken? introduction));
|
||||||
|
Assert.NotNull(introduction);
|
||||||
|
Assert.Equal(created.AttemptId, introduction.AttemptId);
|
||||||
|
Assert.Equal(43, introduction.ConnectionTicket.Length);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
host.Stop();
|
||||||
|
client.Stop();
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrozenV1EnvelopeIsConsumedOnTheLiteNetSocketWithinAmplificationBudget()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "v1-envelope");
|
||||||
|
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
MaxDatagramsPerPoll = 8,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
using UdpClient host = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
using UdpClient client = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||||
|
byte[] hostDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
|
||||||
|
{
|
||||||
|
MessageType = UdpPresenceMessageType.HostPresence,
|
||||||
|
MediationHandle = created.MediationHandle,
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
LocalAddress = "192.168.1.10",
|
||||||
|
LocalPort = 41_000,
|
||||||
|
Capability = hostAttempt.HostPunchCapability,
|
||||||
|
});
|
||||||
|
byte[] clientDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
|
||||||
|
{
|
||||||
|
MessageType = UdpPresenceMessageType.ClientPresence,
|
||||||
|
MediationHandle = created.MediationHandle,
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
LocalAddress = "192.168.1.11",
|
||||||
|
LocalPort = 42_000,
|
||||||
|
Capability = created.ClientPunchCapability,
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await host.SendAsync(hostDatagram, mediator, timeout.Token);
|
||||||
|
await client.SendAsync(clientDatagram, mediator, timeout.Token);
|
||||||
|
UdpReceiveResult hostIntroduction = await host.ReceiveAsync(timeout.Token);
|
||||||
|
UdpReceiveResult clientIntroduction = await client.ReceiveAsync(timeout.Token);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
|
||||||
|
<= clientDatagram.Length * 2,
|
||||||
|
$"The completing authenticated contribution exceeded the 2.0 response-byte budget: "
|
||||||
|
+ $"responses={hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length}, "
|
||||||
|
+ $"request={clientDatagram.Length}.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OversizedMalformedAndGameplayDatagramsReceiveNoResponse()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
MaxDatagramsPerPoll = 8,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
using UdpClient sender = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||||
|
byte[] oversized = new byte[ContractLimits.UdpDatagramMaxBytes + 1];
|
||||||
|
oversized[0] = RendezvousUdpCodec.MagicFirst;
|
||||||
|
oversized[1] = RendezvousUdpCodec.MagicSecond;
|
||||||
|
byte[] gameplayPayload = [0x01, 0x02, 0x03, 0x04];
|
||||||
|
byte[] malformedNative = [17, 0];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await sender.SendAsync(oversized, mediator, timeout.Token);
|
||||||
|
await sender.SendAsync(gameplayPayload, mediator, timeout.Token);
|
||||||
|
await sender.SendAsync(malformedNative, mediator, timeout.Token);
|
||||||
|
using CancellationTokenSource noResponse = new(TimeSpan.FromMilliseconds(150));
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||||
|
await sender.ReceiveAsync(noResponse.Token));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForgedNativeIntroductionResponseCannotReflectToPayloadEndpoint()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
MaxDatagramsPerPoll = 8,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
using UdpClient reflectedTarget = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
using UdpClient responseCapture = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
using UdpClient attacker = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
LiteNetManager generator = new(new EventBasedLiteNetListener()) { NatPunchEnabled = true };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(generator.Start(0));
|
||||||
|
IPEndPoint target = (IPEndPoint)reflectedTarget.Client.LocalEndPoint!;
|
||||||
|
IPEndPoint capture = (IPEndPoint)responseCapture.Client.LocalEndPoint!;
|
||||||
|
generator.NatPunchModule.NatIntroduce(
|
||||||
|
target,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 9),
|
||||||
|
capture,
|
||||||
|
capture,
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||||
|
byte[] forgedResponse = (await responseCapture.ReceiveAsync(timeout.Token)).Buffer;
|
||||||
|
|
||||||
|
await attacker.SendAsync(
|
||||||
|
forgedResponse,
|
||||||
|
Assert.IsType<IPEndPoint>(service.LocalEndpoint),
|
||||||
|
timeout.Token);
|
||||||
|
|
||||||
|
using CancellationTokenSource noReflection = new(TimeSpan.FromMilliseconds(150));
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||||
|
await reflectedTarget.ReceiveAsync(noReflection.Token));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
generator.Stop();
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionHttpEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticatedHttpLifecycleReturnsStableContractsAndStatuses()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
|
EphemeralStoreOptions stateOptions = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||||
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
|
ProvisioningTestData.CreateOptions(),
|
||||||
|
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||||
|
clock.UtcNow);
|
||||||
|
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||||
|
string publisherCredential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||||
|
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
|
options.ThrowOnBadRequest = true);
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||||
|
builder.Services.AddSingleton(provisioning);
|
||||||
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(clock);
|
||||||
|
builder.Services.AddSingleton(capabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||||
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
await using WebApplication app = builder.Build();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.MapRendezvousContractEndpoints();
|
||||||
|
await app.StartAsync();
|
||||||
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
|
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
|
using HttpClient client = new() { BaseAddress = new Uri(address) };
|
||||||
|
RegisterSessionRequest registration = new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "http-register-1",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.4.2",
|
||||||
|
DisplayName = "HTTP host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mode"] = "co-op",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpResponseMessage unauthenticated = await client.PostAsJsonAsync(
|
||||||
|
"/v1/sessions",
|
||||||
|
registration,
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
|
||||||
|
Assert.Equal("Bearer", Assert.Single(unauthenticated.Headers.WwwAuthenticate).Scheme);
|
||||||
|
ApiError? authenticationError = await unauthenticated.Content.ReadFromJsonAsync<ApiError>(
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(RendezvousErrorCode.AuthenticationRequired, authenticationError!.Code);
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||||
|
"Bearer",
|
||||||
|
publisherCredential);
|
||||||
|
string invalidJson = JsonSerializer.Serialize(registration, ContractJson.Options)
|
||||||
|
.Replace("\"public\"", "\"futureVisibility\"", StringComparison.Ordinal);
|
||||||
|
HttpResponseMessage invalid = await client.PostAsync(
|
||||||
|
"/v1/sessions",
|
||||||
|
new StringContent(invalidJson, Encoding.UTF8, "application/json"));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||||
|
ApiError? invalidError = await invalid.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options);
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, invalidError!.Code);
|
||||||
|
|
||||||
|
HttpResponseMessage created = await client.PostAsJsonAsync(
|
||||||
|
"/v1/sessions",
|
||||||
|
registration,
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||||
|
RegisterSessionResponse? session = await created.Content.ReadFromJsonAsync<RegisterSessionResponse>(
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.NotNull(session);
|
||||||
|
Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString);
|
||||||
|
Assert.True(capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint presenceFingerprint));
|
||||||
|
store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
presenceFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||||
|
null));
|
||||||
|
|
||||||
|
BrowseSessionsResponse? browser = await client.GetFromJsonAsync<BrowseSessionsResponse>(
|
||||||
|
"/v1/sessions?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7®ionId=eu-central&pageSize=10&excludeFull=true",
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(session.ListingId, Assert.Single(browser!.Items).ListingId);
|
||||||
|
GetSessionResponse? direct = await client.GetFromJsonAsync<GetSessionResponse>(
|
||||||
|
$"/v1/sessions/{session.ListingId}?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7",
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(session.ListingId, direct!.Session.ListingId);
|
||||||
|
|
||||||
|
HttpResponseMessage renewed = await client.PostAsJsonAsync(
|
||||||
|
$"/v1/sessions/{session.ListingId}/renew",
|
||||||
|
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, renewed.StatusCode);
|
||||||
|
Assert.NotNull(await renewed.Content.ReadFromJsonAsync<RenewLeaseResponse>(ContractJson.Options));
|
||||||
|
|
||||||
|
HttpResponseMessage updated = await client.PutAsJsonAsync(
|
||||||
|
$"/v1/sessions/{session.ListingId}",
|
||||||
|
new UpdateSessionRequest
|
||||||
|
{
|
||||||
|
LeaseToken = session.LeaseToken,
|
||||||
|
BuildVersion = "1.4.3",
|
||||||
|
DisplayName = "HTTP host updated",
|
||||||
|
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
|
||||||
|
},
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
|
||||||
|
|
||||||
|
using HttpRequestMessage deleteRequest = new(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"/v1/sessions/{session.ListingId}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(
|
||||||
|
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
|
||||||
|
options: ContractJson.Options),
|
||||||
|
};
|
||||||
|
HttpResponseMessage deleted = await client.SendAsync(deleteRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, store.GetListing(session.ListingId, false).Code);
|
||||||
|
|
||||||
|
await app.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionLeaseServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RegistrationReturnsOpaqueCredentialsButRemainsHiddenUntilPresence()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
|
||||||
|
RegisterSessionResponse response = fixture.Register();
|
||||||
|
|
||||||
|
Assert.Equal(30, response.LeaseRenewAfterSeconds);
|
||||||
|
Assert.Equal(10, response.HostPresenceRefreshAfterSeconds);
|
||||||
|
Assert.Equal(43, response.LeaseToken.Length);
|
||||||
|
Assert.Equal(43, response.HostPresenceCapability.Length);
|
||||||
|
Assert.Empty(fixture.Browse());
|
||||||
|
string json = JsonSerializer.Serialize(response, ContractJson.Options);
|
||||||
|
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("fingerprint", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("store", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExactRegistrationRetryReproducesIdsAndCapabilitiesWithoutRetainingPlaintext()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionRequest request = fixture.Request("same-key");
|
||||||
|
|
||||||
|
RegisterSessionResponse first = fixture.Register(request);
|
||||||
|
RegisterSessionRequest reordered = fixture.Request("same-key");
|
||||||
|
reordered.Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["map"] = "europa",
|
||||||
|
["mode"] = "co-op",
|
||||||
|
};
|
||||||
|
RegisterSessionResponse duplicate = fixture.Register(reordered);
|
||||||
|
RegisterSessionRequest changedRequest = fixture.Request("same-key");
|
||||||
|
changedRequest.DisplayName = "Changed";
|
||||||
|
SessionServiceResult<RegisterSessionResponse> changed = fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
changedRequest);
|
||||||
|
|
||||||
|
Assert.Equal(first.ListingId, duplicate.ListingId);
|
||||||
|
Assert.Equal(first.LeaseId, duplicate.LeaseId);
|
||||||
|
Assert.Equal(first.LeaseToken, duplicate.LeaseToken);
|
||||||
|
Assert.Equal(first.HostPresenceHandle, duplicate.HostPresenceHandle);
|
||||||
|
Assert.Equal(first.HostPresenceCapability, duplicate.HostPresenceCapability);
|
||||||
|
Assert.Equal(RendezvousErrorCode.Conflict, changed.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReRegistrationAfterIdempotencyExpiryRotatesIdsAndCapabilities()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new()
|
||||||
|
{
|
||||||
|
LeaseLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
JoinAttemptLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
IdempotencyLifetime = TimeSpan.FromSeconds(6),
|
||||||
|
};
|
||||||
|
using SessionLeaseFixture fixture = new(options);
|
||||||
|
RegisterSessionRequest request = fixture.Request("reused-after-expiry");
|
||||||
|
RegisterSessionResponse first = fixture.Register(request);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(options.IdempotencyLifetime);
|
||||||
|
RegisterSessionResponse second = fixture.Register(request);
|
||||||
|
|
||||||
|
Assert.NotEqual(first.ListingId, second.ListingId);
|
||||||
|
Assert.NotEqual(first.LeaseId, second.LeaseId);
|
||||||
|
Assert.NotEqual(first.LeaseToken, second.LeaseToken);
|
||||||
|
Assert.NotEqual(first.HostPresenceCapability, second.HostPresenceCapability);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresenceTransitionsAwaitingToListedToStaleAndBackWithoutChangingIdentity()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
|
||||||
|
Assert.Empty(fixture.Browse());
|
||||||
|
Assert.True(fixture.BindPresence(registration).Succeeded);
|
||||||
|
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(fixture.StoreOptions.PresenceLifetime);
|
||||||
|
Assert.Empty(fixture.Browse());
|
||||||
|
Assert.True(fixture.BindPresence(registration).Succeeded);
|
||||||
|
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenewUpdateAndDeleteMaintainCanonicalIdentityAndAdvisoryCapacity()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
SessionServiceResult<RenewLeaseResponse> renewed = fixture.Service.Renew(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken });
|
||||||
|
SessionServiceResult<bool> updated = fixture.Service.Update(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
LeaseToken = registration.LeaseToken,
|
||||||
|
BuildVersion = "1.4.3",
|
||||||
|
DisplayName = "Europa Updated",
|
||||||
|
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mode"] = "co-op",
|
||||||
|
["map"] = "europa",
|
||||||
|
},
|
||||||
|
DedicatedFallback = new()
|
||||||
|
{
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
Address = "203.0.113.92",
|
||||||
|
Port = 9_062,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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.Equal("203.0.113.92", stored.Definition.DedicatedFallback!.Address);
|
||||||
|
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 DisabledFallbackPolicyRejectsRegistrationAndUpdateEndpoints()
|
||||||
|
{
|
||||||
|
GamePolicyOptions policy = ProvisioningTestData.CreatePolicy();
|
||||||
|
policy.FallbackPolicy = FallbackPolicyMode.Disabled;
|
||||||
|
using SessionLeaseFixture fixture = new(policyOptions: policy);
|
||||||
|
RegisterSessionRequest registrationRequest = fixture.Request();
|
||||||
|
registrationRequest.DedicatedFallback = new()
|
||||||
|
{
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
Address = "203.0.113.94",
|
||||||
|
Port = 9_064,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.Forbidden,
|
||||||
|
fixture.Service.Register(fixture.Principal, registrationRequest).Error);
|
||||||
|
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.Forbidden,
|
||||||
|
fixture.Service.Update(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new UpdateSessionRequest
|
||||||
|
{
|
||||||
|
LeaseToken = registration.LeaseToken,
|
||||||
|
BuildVersion = "1.4.3",
|
||||||
|
DisplayName = "Europa Updated",
|
||||||
|
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mode"] = "co-op",
|
||||||
|
["map"] = "europa",
|
||||||
|
},
|
||||||
|
DedicatedFallback = registrationRequest.DedicatedFallback,
|
||||||
|
}).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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,97 @@
|
|||||||
|
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,
|
||||||
|
GamePolicyOptions? policyOptions = null)
|
||||||
|
{
|
||||||
|
StoreOptions = storeOptions ?? new EphemeralStoreOptions();
|
||||||
|
Clock = new();
|
||||||
|
Store = new(StoreOptions, Clock, Clock);
|
||||||
|
Capabilities = new();
|
||||||
|
GamePolicyRegistry policies = GamePolicyRegistry.Create([
|
||||||
|
policyOptions ?? ProvisioningTestData.CreatePolicy(),
|
||||||
|
]);
|
||||||
|
Service = new(
|
||||||
|
new PublisherAuthorizationService(policies),
|
||||||
|
Store,
|
||||||
|
Capabilities,
|
||||||
|
SessionLeaseTiming.From(StoreOptions),
|
||||||
|
Clock);
|
||||||
|
Principal = Publisher("publisher-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public EphemeralStoreOptions StoreOptions { get; }
|
||||||
|
public ManualRendezvousClock Clock { get; }
|
||||||
|
public InMemoryEphemeralRendezvousStore Store { get; }
|
||||||
|
public EphemeralCapabilityIssuer Capabilities { get; }
|
||||||
|
public SessionLeaseService Service { get; }
|
||||||
|
public DedicatedPublisherPrincipal Principal { get; }
|
||||||
|
public TenantScope Scope { get; } = new(new("space-game"), new("production"));
|
||||||
|
|
||||||
|
public DedicatedPublisherPrincipal Publisher(string subject) => new(
|
||||||
|
subject,
|
||||||
|
Clock.UtcNow.AddMinutes(10),
|
||||||
|
Scope.GameId,
|
||||||
|
Scope.EnvironmentId,
|
||||||
|
new HashSet<RegionId> { new("eu-central") });
|
||||||
|
|
||||||
|
public RegisterSessionRequest Request(string? idempotencyKey = null) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = idempotencyKey ?? $"register-{Interlocked.Increment(ref _sequence)}",
|
||||||
|
GameId = Scope.GameId,
|
||||||
|
EnvironmentId = Scope.EnvironmentId,
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.4.2",
|
||||||
|
DisplayName = "Europa Relay",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mode"] = "co-op",
|
||||||
|
["map"] = "europa",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public RegisterSessionResponse Register(
|
||||||
|
RegisterSessionRequest? request = null,
|
||||||
|
DedicatedPublisherPrincipal? principal = null)
|
||||||
|
{
|
||||||
|
SessionServiceResult<RegisterSessionResponse> result = Service.Register(
|
||||||
|
principal ?? Principal,
|
||||||
|
request ?? Request());
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
return result.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoreResult<StoredListing> BindPresence(RegisterSessionResponse registration)
|
||||||
|
{
|
||||||
|
Assert.True(Capabilities.TryFingerprint(
|
||||||
|
registration.HostPresenceCapability,
|
||||||
|
out SecretFingerprint fingerprint));
|
||||||
|
return Store.BindHostPresence(new(
|
||||||
|
registration.HostPresenceHandle,
|
||||||
|
fingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.50", 40_000),
|
||||||
|
new ObservedEndpoint(AddressFamilyKind.Ipv4, "192.168.1.50", 40_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<StoredListing> Browse() => Store.BrowseVisibleListings(new(
|
||||||
|
Scope,
|
||||||
|
7,
|
||||||
|
new RegionId("eu-central"))).Value!;
|
||||||
|
|
||||||
|
public void Dispose() => Capabilities.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
internal sealed class ManualRendezvousClock : IWallClock, IMonotonicClock
|
||||||
|
{
|
||||||
|
public ManualRendezvousClock(DateTimeOffset? utcNow = null) =>
|
||||||
|
UtcNow = utcNow ?? new DateTimeOffset(2026, 7, 16, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
public DateTimeOffset UtcNow { get; private set; }
|
||||||
|
public TimeSpan Elapsed { get; private set; }
|
||||||
|
|
||||||
|
public void Advance(TimeSpan duration)
|
||||||
|
{
|
||||||
|
Elapsed += duration;
|
||||||
|
UtcNow += duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MoveWall(TimeSpan duration) => UtcNow += duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class EphemeralStateFixture
|
||||||
|
{
|
||||||
|
private int _sequence;
|
||||||
|
|
||||||
|
public EphemeralStateFixture(EphemeralStoreOptions? options = null)
|
||||||
|
{
|
||||||
|
Clock = new();
|
||||||
|
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ManualRendezvousClock Clock { get; }
|
||||||
|
public InMemoryEphemeralRendezvousStore Store { get; }
|
||||||
|
public TenantScope Scope { get; } = new(new GameId("space-game"), new EnvironmentId("test"));
|
||||||
|
|
||||||
|
public CreateListingCommand ListingCommand(
|
||||||
|
string owner = "publisher-1",
|
||||||
|
string? idempotencyKey = null,
|
||||||
|
string? requestFingerprint = null)
|
||||||
|
{
|
||||||
|
int sequence = Interlocked.Increment(ref _sequence);
|
||||||
|
return new(
|
||||||
|
idempotencyKey ?? $"register-{sequence}",
|
||||||
|
requestFingerprint ?? $"request-{sequence}",
|
||||||
|
new ListingDefinition
|
||||||
|
{
|
||||||
|
ListingId = NewListingId(),
|
||||||
|
LeaseId = NewLeaseId(),
|
||||||
|
Scope = Scope,
|
||||||
|
OwnerSubject = owner,
|
||||||
|
RegionId = new RegionId("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.2.3",
|
||||||
|
DisplayName = "Test host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
TrustMode = PublisherTrustMode.ManagedDedicated,
|
||||||
|
CurrentPlayers = 1,
|
||||||
|
MaximumPlayers = 8,
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal) { ["mode"] = "coop" },
|
||||||
|
LeaseFingerprint = Fingerprint($"lease-{sequence}"),
|
||||||
|
HostPresenceHandle = NewHandle(),
|
||||||
|
HostPresenceFingerprint = Fingerprint($"presence-{sequence}"),
|
||||||
|
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredListing CreateVisibleListing(out CreateListingCommand command)
|
||||||
|
{
|
||||||
|
command = ListingCommand();
|
||||||
|
StoreResult<StoredListing> created = Store.CreateListing(command);
|
||||||
|
Assert.True(created.Succeeded);
|
||||||
|
StoreResult<StoredListing> bound = Store.BindHostPresence(new(
|
||||||
|
command.Listing.HostPresenceHandle,
|
||||||
|
command.Listing.HostPresenceFingerprint,
|
||||||
|
PublicEndpoint(40_000),
|
||||||
|
LocalEndpoint(40_000)));
|
||||||
|
Assert.True(bound.Succeeded);
|
||||||
|
return bound.Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreateJoinAttemptCommand AttemptCommand(StoredListing listing, string owner = "client-1")
|
||||||
|
{
|
||||||
|
int sequence = Interlocked.Increment(ref _sequence);
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
IdempotencyOwner = owner,
|
||||||
|
IdempotencyKey = $"join-{sequence}",
|
||||||
|
RequestFingerprint = $"join-request-{sequence}",
|
||||||
|
ClientSubject = owner,
|
||||||
|
AttemptId = NewAttemptId(),
|
||||||
|
MediationHandle = NewHandle(),
|
||||||
|
Scope = listing.Definition.Scope,
|
||||||
|
ListingId = listing.Definition.ListingId,
|
||||||
|
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||||
|
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
|
||||||
|
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
|
||||||
|
ConnectionTicketFingerprint = Fingerprint($"ticket-{sequence}"),
|
||||||
|
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SecretFingerprint Fingerprint(string value) => new(value);
|
||||||
|
public static ObservedEndpoint PublicEndpoint(int port) => new(AddressFamilyKind.Ipv4, "203.0.113.10", port);
|
||||||
|
public static ObservedEndpoint OtherPublicEndpoint(int port) => new(AddressFamilyKind.Ipv4, "198.51.100.20", port);
|
||||||
|
public static ObservedEndpoint LocalEndpoint(int port) => new(AddressFamilyKind.Ipv4, "192.168.1.20", port);
|
||||||
|
public static SessionListingId NewListingId() => new(Guid.NewGuid());
|
||||||
|
public static LeaseId NewLeaseId() => new(Guid.NewGuid());
|
||||||
|
public static JoinAttemptId NewAttemptId() => new(Guid.NewGuid());
|
||||||
|
public static MediationHandle NewHandle() => new(Guid.NewGuid());
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
public sealed class InMemoryEphemeralRendezvousStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IdempotencyRetentionMustCoverResourceLifetimes()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new();
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => new InMemoryEphemeralRendezvousStore(
|
||||||
|
new EphemeralStoreOptions { IdempotencyLifetime = TimeSpan.FromSeconds(5) },
|
||||||
|
clock,
|
||||||
|
clock));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateRegistrationIsIdempotentButChangedRequestConflicts()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
CreateListingCommand command = fixture.ListingCommand();
|
||||||
|
|
||||||
|
StoreResult<StoredListing> first = fixture.Store.CreateListing(command);
|
||||||
|
StoreResult<StoredListing> duplicate = fixture.Store.CreateListing(command);
|
||||||
|
StoreResult<StoredListing> changed = fixture.Store.CreateListing(command with { RequestFingerprint = "different" });
|
||||||
|
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.True(duplicate.Succeeded);
|
||||||
|
Assert.True(duplicate.IsIdempotentReplay);
|
||||||
|
Assert.Equal(first.Value!.Definition.ListingId, duplicate.Value!.Definition.ListingId);
|
||||||
|
Assert.Equal(StoreResultCode.Conflict, changed.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeaseAndPresenceExpiryUseMonotonicTime()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(20));
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, true).Code);
|
||||||
|
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, false).Succeeded);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(40));
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WallClockMovementDoesNotExpireOrExtendLease()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out CreateListingCommand command);
|
||||||
|
|
||||||
|
fixture.Clock.MoveWall(TimeSpan.FromDays(30));
|
||||||
|
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, false).Succeeded);
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||||
|
StoreResult<StoredListing> renewed = fixture.Store.RenewLease(new(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
listing.Definition.LeaseId,
|
||||||
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
|
listing.Version));
|
||||||
|
Assert.Equal(new DateTimeOffset(2026, 7, 16, 0, 1, 1, TimeSpan.Zero), renewed.Value!.LeaseExpiresAt);
|
||||||
|
fixture.Clock.MoveWall(TimeSpan.FromDays(-60));
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(59));
|
||||||
|
Assert.True(fixture.Store.GetListing(listing.Definition.ListingId, false).Succeeded);
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(command.Listing.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RenewDeleteRaceIsAtomicAndDeleteAlwaysWinsEventually()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out CreateListingCommand command);
|
||||||
|
using ManualResetEventSlim start = new(false);
|
||||||
|
|
||||||
|
Task<StoreResult<StoredListing>> renew = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Store.RenewLease(new(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
listing.Definition.LeaseId,
|
||||||
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
|
listing.Version));
|
||||||
|
});
|
||||||
|
Task<StoreResult<bool>> delete = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Store.DeleteListing(new(
|
||||||
|
command.Listing.ListingId,
|
||||||
|
command.Listing.LeaseId,
|
||||||
|
command.Listing.LeaseFingerprint,
|
||||||
|
command.Listing.OwnerSubject));
|
||||||
|
});
|
||||||
|
|
||||||
|
start.Set();
|
||||||
|
await Task.WhenAll(renew, delete);
|
||||||
|
StoreResult<StoredListing> renewResult = await renew;
|
||||||
|
StoreResult<bool> deleteResult = await delete;
|
||||||
|
|
||||||
|
Assert.True(deleteResult.Succeeded);
|
||||||
|
Assert.Contains(renewResult.Code, new[] { StoreResultCode.Success, StoreResultCode.NotFound });
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(command.Listing.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompareAndSwapPreventsStaleRenewal()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
RenewLeaseCommand command = new(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
listing.Definition.LeaseId,
|
||||||
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
|
listing.Version);
|
||||||
|
|
||||||
|
StoreResult<StoredListing> first = fixture.Store.RenewLease(command);
|
||||||
|
StoreResult<StoredListing> stale = fixture.Store.RenewLease(command);
|
||||||
|
|
||||||
|
Assert.Equal(2, first.Value!.Version);
|
||||||
|
Assert.Equal(StoreResultCode.Conflict, stale.Code);
|
||||||
|
Assert.Equal(2, stale.Value!.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JoinRequiresExactScopeProtocolAndFreshHostPresence()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
CreateListingCommand listingCommand = fixture.ListingCommand();
|
||||||
|
StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!;
|
||||||
|
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing);
|
||||||
|
|
||||||
|
Assert.Equal(StoreResultCode.StaleHost, fixture.Store.CreateJoinAttempt(attempt).Code);
|
||||||
|
fixture.Store.BindHostPresence(new(
|
||||||
|
listingCommand.Listing.HostPresenceHandle,
|
||||||
|
listingCommand.Listing.HostPresenceFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(40_000),
|
||||||
|
null));
|
||||||
|
Assert.Equal(StoreResultCode.IncompatibleProtocol, fixture.Store.CreateJoinAttempt(attempt with { ProtocolVersion = 8 }).Code);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with
|
||||||
|
{
|
||||||
|
Scope = new(new("other-game"), new("test")),
|
||||||
|
}).Code);
|
||||||
|
Assert.True(fixture.Store.CreateJoinAttempt(attempt).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateJoinIsIdempotentAndDoesNotAllocateTwice()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
|
||||||
|
|
||||||
|
StoreResult<StoredJoinAttempt> first = fixture.Store.CreateJoinAttempt(command);
|
||||||
|
StoreResult<StoredJoinAttempt> duplicate = fixture.Store.CreateJoinAttempt(command);
|
||||||
|
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.True(duplicate.Succeeded);
|
||||||
|
Assert.True(duplicate.IsIdempotentReplay);
|
||||||
|
Assert.Equal(first.Value!.AttemptId, duplicate.Value!.AttemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BrowseReturnsOnlyFreshPublicCompatibleListingsInStableOrder()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing visible = fixture.CreateVisibleListing(out _);
|
||||||
|
CreateListingCommand staleCommand = fixture.ListingCommand();
|
||||||
|
fixture.Store.CreateListing(staleCommand);
|
||||||
|
CreateListingCommand unlistedCommand = fixture.ListingCommand();
|
||||||
|
unlistedCommand = unlistedCommand with
|
||||||
|
{
|
||||||
|
Listing = unlistedCommand.Listing with { Visibility = ListingVisibility.Unlisted },
|
||||||
|
};
|
||||||
|
fixture.Store.CreateListing(unlistedCommand);
|
||||||
|
fixture.Store.BindHostPresence(new(
|
||||||
|
unlistedCommand.Listing.HostPresenceHandle,
|
||||||
|
unlistedCommand.Listing.HostPresenceFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(40_099),
|
||||||
|
null));
|
||||||
|
|
||||||
|
StoreResult<IReadOnlyList<StoredListing>> result = fixture.Store.BrowseVisibleListings(new(
|
||||||
|
fixture.Scope,
|
||||||
|
visible.Definition.ProtocolVersion,
|
||||||
|
visible.Definition.RegionId));
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.Collection(result.Value!, item => Assert.Equal(visible.Definition.ListingId, item.Definition.ListingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentEndpointBindingAcceptsOneCompleteEndpointOnly()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
|
||||||
|
fixture.Store.CreateJoinAttempt(command);
|
||||||
|
BindAttemptEndpointCommand first = new(
|
||||||
|
command.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
command.ClientCapabilityFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(40_001),
|
||||||
|
EphemeralStateFixture.LocalEndpoint(40_001));
|
||||||
|
BindAttemptEndpointCommand second = first with
|
||||||
|
{
|
||||||
|
PublicEndpoint = EphemeralStateFixture.OtherPublicEndpoint(50_001),
|
||||||
|
LocalEndpoint = null,
|
||||||
|
};
|
||||||
|
using ManualResetEventSlim start = new(false);
|
||||||
|
|
||||||
|
Task<StoreResult<StoredJoinAttempt>> left = Task.Run(() => { start.Wait(); return fixture.Store.BindAttemptEndpoint(first); });
|
||||||
|
Task<StoreResult<StoredJoinAttempt>> right = Task.Run(() => { start.Wait(); return fixture.Store.BindAttemptEndpoint(second); });
|
||||||
|
start.Set();
|
||||||
|
await Task.WhenAll(left, right);
|
||||||
|
StoreResult<StoredJoinAttempt> leftResult = await left;
|
||||||
|
StoreResult<StoredJoinAttempt> rightResult = await right;
|
||||||
|
|
||||||
|
Assert.Equal(1, new[] { leftResult, rightResult }.Count(static result => result.Succeeded));
|
||||||
|
Assert.Equal(1, new[] { leftResult, rightResult }.Count(static result => result.Code == StoreResultCode.ReplayRejected));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttemptCapabilitiesAndIntroductionAreOneTime()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
|
||||||
|
fixture.Store.CreateJoinAttempt(command);
|
||||||
|
BindAttemptEndpointCommand host = new(
|
||||||
|
command.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
command.HostCapabilityFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(40_010),
|
||||||
|
null);
|
||||||
|
BindAttemptEndpointCommand client = new(
|
||||||
|
command.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
command.ClientCapabilityFingerprint,
|
||||||
|
EphemeralStateFixture.OtherPublicEndpoint(40_020),
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.True(fixture.Store.BindAttemptEndpoint(host).Succeeded);
|
||||||
|
Assert.True(fixture.Store.BindAttemptEndpoint(client).Succeeded);
|
||||||
|
Assert.True(fixture.Store.BindAttemptEndpoint(client).IsIdempotentReplay);
|
||||||
|
Assert.True(fixture.Store.ConsumeIntroduction(command.MediationHandle).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.ReplayRejected, fixture.Store.ConsumeIntroduction(command.MediationHandle).Code);
|
||||||
|
Assert.Equal(StoreResultCode.ReplayRejected, fixture.Store.BindAttemptEndpoint(client with
|
||||||
|
{
|
||||||
|
PublicEndpoint = EphemeralStateFixture.OtherPublicEndpoint(40_021),
|
||||||
|
}).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExpiredAttemptCannotBeObservedBoundOrConsumed()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
|
||||||
|
fixture.Store.CreateJoinAttempt(command);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
|
||||||
|
command.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
command.ClientCapabilityFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(40_050),
|
||||||
|
null)).Code);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.ConsumeIntroduction(command.MediationHandle).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenericReplayConsumptionIsBoundedAndExpires()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new() { MaxReplayEntries = 1 };
|
||||||
|
EphemeralStateFixture fixture = new(options);
|
||||||
|
|
||||||
|
Assert.True(fixture.Store.ConsumeReplay(new("ticket", "one")).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.ReplayRejected, fixture.Store.ConsumeReplay(new("ticket", "one")).Code);
|
||||||
|
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.ConsumeReplay(new("ticket", "two")).Code);
|
||||||
|
fixture.Clock.Advance(options.ReplayLifetime);
|
||||||
|
Assert.True(fixture.Store.ConsumeReplay(new("ticket", "two")).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresenceAttemptAndRevocationPoolsShedWithoutPartialMutation()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new()
|
||||||
|
{
|
||||||
|
MaxPresenceBindings = 1,
|
||||||
|
MaxJoinAttempts = 1,
|
||||||
|
MaxRevocations = 1,
|
||||||
|
};
|
||||||
|
EphemeralStateFixture fixture = new(options);
|
||||||
|
StoredListing first = fixture.CreateVisibleListing(out CreateListingCommand firstCommand);
|
||||||
|
CreateListingCommand secondCommand = fixture.ListingCommand(owner: "publisher-2");
|
||||||
|
fixture.Store.CreateListing(secondCommand);
|
||||||
|
|
||||||
|
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.BindHostPresence(new(
|
||||||
|
secondCommand.Listing.HostPresenceHandle,
|
||||||
|
secondCommand.Listing.HostPresenceFingerprint,
|
||||||
|
EphemeralStateFixture.OtherPublicEndpoint(42_000),
|
||||||
|
null)).Code);
|
||||||
|
Assert.True(fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(first)).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(first, "client-2")).Code);
|
||||||
|
Assert.True(fixture.Store.RevokePrincipal("unrelated", TimeSpan.FromMinutes(1)).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.RevokePrincipal(firstCommand.Listing.OwnerSubject, TimeSpan.FromMinutes(1)).Code);
|
||||||
|
Assert.True(fixture.Store.GetListing(first.Definition.ListingId, true).Succeeded);
|
||||||
|
Assert.True(fixture.Store.GetListing(secondCommand.Listing.ListingId, false).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExhaustionShedsNewListingWithoutMutatingExistingState()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new() { MaxListings = 1 };
|
||||||
|
EphemeralStateFixture fixture = new(options);
|
||||||
|
CreateListingCommand first = fixture.ListingCommand();
|
||||||
|
CreateListingCommand second = fixture.ListingCommand();
|
||||||
|
|
||||||
|
Assert.True(fixture.Store.CreateListing(first).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
|
||||||
|
Assert.True(fixture.Store.GetListing(first.Listing.ListingId, false).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(second.Listing.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IdempotencyPoolExhaustionDoesNotCreateUntrackedResource()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new() { MaxListings = 2, MaxIdempotencyEntries = 1 };
|
||||||
|
EphemeralStateFixture fixture = new(options);
|
||||||
|
CreateListingCommand first = fixture.ListingCommand();
|
||||||
|
CreateListingCommand second = fixture.ListingCommand();
|
||||||
|
|
||||||
|
Assert.True(fixture.Store.CreateListing(first).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
|
||||||
|
Assert.True(fixture.Store.GetListing(first.Listing.ListingId, false).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(second.Listing.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PolicyQuotasAreCheckedInsideAtomicCreation()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
CreateListingCommand first = fixture.ListingCommand(owner: "publisher-quota") with { OwnerListingLimit = 1 };
|
||||||
|
CreateListingCommand second = fixture.ListingCommand(owner: "publisher-quota") with { OwnerListingLimit = 1 };
|
||||||
|
Assert.True(fixture.Store.CreateListing(first).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateListing(second).Code);
|
||||||
|
|
||||||
|
fixture.Store.BindHostPresence(new(
|
||||||
|
first.Listing.HostPresenceHandle,
|
||||||
|
first.Listing.HostPresenceFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(42_100),
|
||||||
|
null));
|
||||||
|
StoredListing listing = fixture.Store.GetListing(first.Listing.ListingId, true).Value!;
|
||||||
|
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing) with { ScopeAttemptLimit = 1 };
|
||||||
|
Assert.True(fixture.Store.CreateJoinAttempt(attempt).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.CapacityExceeded, fixture.Store.CreateJoinAttempt(
|
||||||
|
fixture.AttemptCommand(listing, "client-quota-2") with { ScopeAttemptLimit = 1 }).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidDefaultSecurityValuesCannotEnterStore()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
CreateListingCommand command = fixture.ListingCommand();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentException>(() => fixture.Store.CreateListing(command with
|
||||||
|
{
|
||||||
|
Listing = command.Listing with { LeaseFingerprint = default },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RevocationRemovesEveryPathAndBlocksNewWorkAtomically()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out CreateListingCommand command);
|
||||||
|
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing, command.Listing.OwnerSubject);
|
||||||
|
fixture.Store.CreateJoinAttempt(attempt);
|
||||||
|
|
||||||
|
StoreResult<int> revoked = fixture.Store.RevokePrincipal(command.Listing.OwnerSubject, TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
Assert.True(revoked.Succeeded);
|
||||||
|
Assert.Equal(2, revoked.Value);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
attempt.ClientCapabilityFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(40_030),
|
||||||
|
null)).Code);
|
||||||
|
Assert.Equal(StoreResultCode.Revoked, fixture.Store.CreateListing(fixture.ListingCommand(owner: command.Listing.OwnerSubject)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RestartHasNewGenerationAndNoEphemeralState()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture before = new();
|
||||||
|
StoredListing listing = before.CreateVisibleListing(out _);
|
||||||
|
EphemeralStateFixture after = new();
|
||||||
|
|
||||||
|
Assert.NotEqual(before.Store.InstanceId, after.Store.InstanceId);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, after.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrainRejectsNewWorkAllowsInflightCompletionThenClearsState()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new() { GracefulDrainLifetime = TimeSpan.FromSeconds(5) };
|
||||||
|
EphemeralStateFixture fixture = new(options);
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing);
|
||||||
|
fixture.Store.CreateJoinAttempt(attempt);
|
||||||
|
fixture.Store.BeginDrain();
|
||||||
|
|
||||||
|
Assert.Equal(StoreResultCode.Draining, fixture.Store.CreateListing(fixture.ListingCommand()).Code);
|
||||||
|
Assert.Equal(StoreResultCode.Draining, fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(listing)).Code);
|
||||||
|
Assert.True(fixture.Store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
attempt.ClientCapabilityFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(41_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(options.GracefulDrainLifetime);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
attempt.HostCapabilityFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(41_001),
|
||||||
|
null)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrecancelledOperationHasNoPartialEffect()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
CreateListingCommand command = fixture.ListingCommand();
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
cancellation.Cancel();
|
||||||
|
|
||||||
|
Assert.Throws<OperationCanceledException>(() => fixture.Store.CreateListing(command, cancellation.Token));
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(command.Listing.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnavailableStoreFailsNewAuthorizationClosedAndErasesActiveState()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
fixture.Store.MarkUnavailable();
|
||||||
|
|
||||||
|
Assert.Equal(StoreResultCode.ServiceUnavailable, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
|
||||||
|
Assert.Equal(StoreResultCode.ServiceUnavailable, fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(listing)).Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
public sealed class StoreResultMappingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EveryStoreResultHasOneSharedContractErrorMapping()
|
||||||
|
{
|
||||||
|
Dictionary<StoreResultCode, RendezvousErrorCode> expected = new()
|
||||||
|
{
|
||||||
|
[StoreResultCode.Success] = RendezvousErrorCode.None,
|
||||||
|
[StoreResultCode.NotFound] = RendezvousErrorCode.NotFound,
|
||||||
|
[StoreResultCode.Expired] = RendezvousErrorCode.Expired,
|
||||||
|
[StoreResultCode.Revoked] = RendezvousErrorCode.Forbidden,
|
||||||
|
[StoreResultCode.Conflict] = RendezvousErrorCode.Conflict,
|
||||||
|
[StoreResultCode.CapacityExceeded] = RendezvousErrorCode.CapacityExceeded,
|
||||||
|
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
|
||||||
|
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
[StoreResultCode.StaleHost] = RendezvousErrorCode.StaleHost,
|
||||||
|
[StoreResultCode.IncompatibleProtocol] = RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count);
|
||||||
|
foreach (KeyValuePair<StoreResultCode, RendezvousErrorCode> item in expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(item.Value, item.Key.ToContractError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.TestClient;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.TestClient;
|
||||||
|
|
||||||
|
public sealed class DirectEchoProtocolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task HostReleasesPendingNonceWhenPeerDisconnectsBeforeAcknowledgement()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
|
||||||
|
EventBasedNetListener hostEvents = new();
|
||||||
|
EventBasedNetListener clientEvents = new();
|
||||||
|
hostEvents.ConnectionRequestEvent += request => request.Accept();
|
||||||
|
NetPeer? clientPeer = null;
|
||||||
|
clientEvents.PeerConnectedEvent += peer => clientPeer = peer;
|
||||||
|
NetManager hostManager = new(hostEvents);
|
||||||
|
NetManager clientManager = new(clientEvents);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(hostManager.Start(0));
|
||||||
|
Assert.True(clientManager.Start(0));
|
||||||
|
clientManager.Connect(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||||
|
"echo-test");
|
||||||
|
await PumpUntilAsync(
|
||||||
|
() => clientPeer is not null,
|
||||||
|
hostManager,
|
||||||
|
clientManager,
|
||||||
|
clientManager,
|
||||||
|
timeout.Token);
|
||||||
|
using DirectEchoProtocol host = new(hostEvents, host: true);
|
||||||
|
clientPeer!.Send(
|
||||||
|
Encoding.ASCII.GetBytes("rv1-ping:00112233445566778899aabbccddeeff"),
|
||||||
|
DeliveryMethod.ReliableOrdered);
|
||||||
|
await PumpUntilAsync(
|
||||||
|
() => host.PendingHostExchangeCount == 1,
|
||||||
|
hostManager,
|
||||||
|
clientManager,
|
||||||
|
clientManager,
|
||||||
|
timeout.Token);
|
||||||
|
|
||||||
|
clientPeer.Disconnect();
|
||||||
|
await PumpUntilAsync(
|
||||||
|
() => host.PendingHostExchangeCount == 0,
|
||||||
|
hostManager,
|
||||||
|
clientManager,
|
||||||
|
clientManager,
|
||||||
|
timeout.Token);
|
||||||
|
|
||||||
|
Assert.Equal(0, host.PendingHostExchangeCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
clientManager.Stop();
|
||||||
|
hostManager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostVerifiesOverlappingPeersAgainstTheirOwnNonces()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
|
||||||
|
EventBasedNetListener hostEvents = new();
|
||||||
|
EventBasedNetListener firstEvents = new();
|
||||||
|
EventBasedNetListener secondEvents = new();
|
||||||
|
hostEvents.ConnectionRequestEvent += request => request.Accept();
|
||||||
|
NetPeer? firstPeer = null;
|
||||||
|
NetPeer? secondPeer = null;
|
||||||
|
firstEvents.PeerConnectedEvent += peer => firstPeer = peer;
|
||||||
|
secondEvents.PeerConnectedEvent += peer => secondPeer = peer;
|
||||||
|
NetManager hostManager = new(hostEvents);
|
||||||
|
NetManager firstManager = new(firstEvents);
|
||||||
|
NetManager secondManager = new(secondEvents);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(hostManager.Start(0));
|
||||||
|
Assert.True(firstManager.Start(0));
|
||||||
|
Assert.True(secondManager.Start(0));
|
||||||
|
IPEndPoint hostEndpoint = new(IPAddress.Loopback, hostManager.LocalPort);
|
||||||
|
firstManager.Connect(hostEndpoint, "echo-test");
|
||||||
|
secondManager.Connect(hostEndpoint, "echo-test");
|
||||||
|
await PumpUntilAsync(
|
||||||
|
() => firstPeer is not null && secondPeer is not null,
|
||||||
|
hostManager,
|
||||||
|
firstManager,
|
||||||
|
secondManager,
|
||||||
|
timeout.Token);
|
||||||
|
|
||||||
|
using DirectEchoProtocol host = new(hostEvents, host: true);
|
||||||
|
using DirectEchoProtocol first = new(firstEvents, host: false);
|
||||||
|
using DirectEchoProtocol second = new(secondEvents, host: false);
|
||||||
|
int hostCompletions = 0;
|
||||||
|
host.ExchangeCompleted += _ => hostCompletions++;
|
||||||
|
first.BeginJoin(firstPeer!);
|
||||||
|
second.BeginJoin(secondPeer!);
|
||||||
|
|
||||||
|
await PumpUntilAsync(
|
||||||
|
() => first.Completion.IsCompleted
|
||||||
|
&& second.Completion.IsCompleted
|
||||||
|
&& hostCompletions == 2,
|
||||||
|
hostManager,
|
||||||
|
firstManager,
|
||||||
|
secondManager,
|
||||||
|
timeout.Token);
|
||||||
|
|
||||||
|
Assert.Equal(2, hostCompletions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
firstManager.Stop();
|
||||||
|
secondManager.Stop();
|
||||||
|
hostManager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task PumpUntilAsync(
|
||||||
|
Func<bool> predicate,
|
||||||
|
NetManager host,
|
||||||
|
NetManager first,
|
||||||
|
NetManager second,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!predicate())
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
host.PollEvents();
|
||||||
|
first.PollEvents();
|
||||||
|
second.PollEvents();
|
||||||
|
await Task.Delay(2, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.TestClient;
|
||||||
|
|
||||||
|
public sealed class TestClientCommandTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HostOptionsParseBoundedPublicConfigurationWithoutAcceptingASecretArgument()
|
||||||
|
{
|
||||||
|
TestClientParseResult parsed = TestClientOptionParser.Parse(
|
||||||
|
[
|
||||||
|
"host",
|
||||||
|
"--service", "https://rendezvous.example/base",
|
||||||
|
"--mediator", "127.0.0.1:9050",
|
||||||
|
"--game", "space-game",
|
||||||
|
"--environment", "production",
|
||||||
|
"--region", "eu-central",
|
||||||
|
"--protocol", "7",
|
||||||
|
"--metadata", "mode=online-coop",
|
||||||
|
"--fallback", "203.0.113.50:7777",
|
||||||
|
"--publisher-credential-env", "TEST_PUBLISHER_CREDENTIAL",
|
||||||
|
"--script",
|
||||||
|
"--json",
|
||||||
|
"--exit-after-echo",
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.True(parsed.Succeeded, parsed.Error);
|
||||||
|
TestClientOptions options = Assert.IsType<TestClientOptions>(parsed.Options);
|
||||||
|
Assert.Equal(TestClientMode.Host, options.Mode);
|
||||||
|
Assert.Equal(new Uri("https://rendezvous.example/base/"), options.ServiceUri);
|
||||||
|
Assert.Equal(7u, options.ProtocolVersion);
|
||||||
|
Assert.Equal("online-coop", options.Metadata["mode"]);
|
||||||
|
Assert.Equal("203.0.113.50", options.DedicatedFallback?.Address);
|
||||||
|
Assert.Equal(7777, options.DedicatedFallback?.Port);
|
||||||
|
Assert.Equal("TEST_PUBLISHER_CREDENTIAL", options.PublisherCredentialEnvironmentVariable);
|
||||||
|
Assert.True(options.Script);
|
||||||
|
Assert.True(options.Json);
|
||||||
|
Assert.True(options.ExitAfterEcho);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(20), options.RunDuration);
|
||||||
|
|
||||||
|
TestClientParseResult secret = TestClientOptionParser.Parse(
|
||||||
|
["host", "--publisher-credential", "secret-canary"]);
|
||||||
|
Assert.False(secret.Succeeded);
|
||||||
|
Assert.Contains("Unknown option", secret.Error, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptExitCodesRemainStable()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, (int)TestClientExitCode.Success);
|
||||||
|
Assert.Equal(2, (int)TestClientExitCode.Usage);
|
||||||
|
Assert.Equal(3, (int)TestClientExitCode.Configuration);
|
||||||
|
Assert.Equal(10, (int)TestClientExitCode.ServiceFailure);
|
||||||
|
Assert.Equal(11, (int)TestClientExitCode.NoCompatibleSession);
|
||||||
|
Assert.Equal(12, (int)TestClientExitCode.TraversalFailed);
|
||||||
|
Assert.Equal(13, (int)TestClientExitCode.DirectTrafficFailed);
|
||||||
|
Assert.Equal(130, (int)TestClientExitCode.Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries()
|
||||||
|
{
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
HostServiceFailureBudget authority = new();
|
||||||
|
Assert.True(authority.ShouldStop(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
now.AddMinutes(1),
|
||||||
|
now));
|
||||||
|
|
||||||
|
HostServiceFailureBudget transient = new();
|
||||||
|
Assert.False(transient.ShouldStop(
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
now.AddMinutes(1),
|
||||||
|
now));
|
||||||
|
Assert.False(transient.ShouldStop(
|
||||||
|
RendezvousErrorCode.RateLimited,
|
||||||
|
now.AddMinutes(1),
|
||||||
|
now));
|
||||||
|
Assert.True(transient.ShouldStop(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
now.AddMinutes(1),
|
||||||
|
now));
|
||||||
|
|
||||||
|
transient.Reset();
|
||||||
|
Assert.True(transient.ShouldStop(
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
now,
|
||||||
|
now));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://user:password@rendezvous.example/")]
|
||||||
|
[InlineData("file:///tmp/rendezvous")]
|
||||||
|
[InlineData("https://rendezvous.example/?token=secret")]
|
||||||
|
public void ServiceUrlRejectsCredentialAndNonHttpShapes(string url)
|
||||||
|
{
|
||||||
|
TestClientParseResult parsed = TestClientOptionParser.Parse(["browse", "--service", url]);
|
||||||
|
|
||||||
|
Assert.False(parsed.Succeeded);
|
||||||
|
Assert.Contains("service URL", parsed.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplicationRoutesParsedOptionsThroughTheInjectableUiFlow()
|
||||||
|
{
|
||||||
|
FakeCommandRunner runner = new(TestClientExitCode.NoCompatibleSession);
|
||||||
|
TestClientApplication application = new(runner);
|
||||||
|
StringWriter output = new();
|
||||||
|
StringWriter error = new();
|
||||||
|
|
||||||
|
int exitCode = await application.RunAsync(
|
||||||
|
["browse", "--script", "--json"],
|
||||||
|
new StringReader(string.Empty),
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal((int)TestClientExitCode.NoCompatibleSession, exitCode);
|
||||||
|
Assert.NotNull(runner.Options);
|
||||||
|
Assert.Equal(TestClientMode.Browse, runner.Options.Mode);
|
||||||
|
Assert.True(runner.Options.Script);
|
||||||
|
using JsonDocument item = JsonDocument.Parse(output.ToString());
|
||||||
|
Assert.Equal(1, item.RootElement.GetProperty("version").GetInt32());
|
||||||
|
Assert.Equal("fake.completed", item.RootElement.GetProperty("event").GetString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvalidArgumentsFailBeforeTheRunnerAndDoNotEchoTheValue()
|
||||||
|
{
|
||||||
|
FakeCommandRunner runner = new(TestClientExitCode.Success);
|
||||||
|
TestClientApplication application = new(runner);
|
||||||
|
StringWriter output = new();
|
||||||
|
StringWriter error = new();
|
||||||
|
|
||||||
|
int exitCode = await application.RunAsync(
|
||||||
|
["host", "--publisher-credential", "secret-canary"],
|
||||||
|
new StringReader(string.Empty),
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal((int)TestClientExitCode.Usage, exitCode);
|
||||||
|
Assert.Null(runner.Options);
|
||||||
|
Assert.DoesNotContain("secret-canary", error.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("host", "--json", "--unknown", "value", "cli.usage", 2)]
|
||||||
|
[InlineData("host", "--json", "--help", "", "cli.help", 0)]
|
||||||
|
public async Task JsonModeKeepsHelpAndUsageFailuresMachineReadable(
|
||||||
|
string mode,
|
||||||
|
string json,
|
||||||
|
string option,
|
||||||
|
string value,
|
||||||
|
string expectedEvent,
|
||||||
|
int expectedExit)
|
||||||
|
{
|
||||||
|
FakeCommandRunner runner = new(TestClientExitCode.Success);
|
||||||
|
TestClientApplication application = new(runner);
|
||||||
|
StringWriter output = new();
|
||||||
|
StringWriter error = new();
|
||||||
|
string[] args = string.IsNullOrEmpty(value)
|
||||||
|
? [mode, json, option]
|
||||||
|
: [mode, json, option, value];
|
||||||
|
|
||||||
|
int exitCode = await application.RunAsync(
|
||||||
|
args,
|
||||||
|
new StringReader(string.Empty),
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(expectedExit, exitCode);
|
||||||
|
string jsonLine = expectedExit == 0 ? output.ToString() : error.ToString();
|
||||||
|
using JsonDocument item = JsonDocument.Parse(jsonLine);
|
||||||
|
Assert.Equal(expectedEvent, item.RootElement.GetProperty("event").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HumanOutputNeutralizesControlCharactersFromPublicListingText()
|
||||||
|
{
|
||||||
|
StringWriter output = new();
|
||||||
|
TestClientOutput sink = new(output, new StringWriter(), json: false);
|
||||||
|
|
||||||
|
sink.Write(
|
||||||
|
"browse.session",
|
||||||
|
"available",
|
||||||
|
displayName: "host\nforged-line\u001b[31m outcome=connected\u2028next\u2029line\u202eright");
|
||||||
|
|
||||||
|
string line = output.ToString();
|
||||||
|
Assert.Equal(1, line.Count(static character => character == '\n'));
|
||||||
|
Assert.DoesNotContain('\u001b', line);
|
||||||
|
Assert.DoesNotContain('\u2028', line);
|
||||||
|
Assert.DoesNotContain('\u2029', line);
|
||||||
|
Assert.DoesNotContain('\u202e', line);
|
||||||
|
Assert.Contains("name=\"host?forged-line?[31m outcome=connected?next?line?right\"", line, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeCommandRunner(TestClientExitCode exitCode) : ITestClientCommandRunner
|
||||||
|
{
|
||||||
|
internal TestClientOptions? Options { get; private set; }
|
||||||
|
|
||||||
|
public Task<TestClientExitCode> RunAsync(
|
||||||
|
TestClientOptions options,
|
||||||
|
TestClientOutput output,
|
||||||
|
TextReader input,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Options = options;
|
||||||
|
output.Write("fake.completed", "complete", phase: "test");
|
||||||
|
return Task.FromResult(exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.TestClient;
|
||||||
|
|
||||||
|
public sealed class TestClientProcessIntegrationTests
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
private const string BuildConfiguration = "Debug";
|
||||||
|
#else
|
||||||
|
private const string BuildConfiguration = "Release";
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerHostAndJoinProcessesExchangeAuthenticatedDirectTrafficWithoutLeakingSecrets()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30));
|
||||||
|
string root = RepositoryRoot();
|
||||||
|
string serverAssembly = Path.Combine(
|
||||||
|
root,
|
||||||
|
$"src/FinalFactory.Rendezvous.Server/bin/{BuildConfiguration}/net10.0/FinalFactory.Rendezvous.Server.dll");
|
||||||
|
string clientAssembly = Path.Combine(
|
||||||
|
root,
|
||||||
|
$"src/FinalFactory.Rendezvous.TestClient/bin/{BuildConfiguration}/net8.0/FinalFactory.Rendezvous.TestClient.dll");
|
||||||
|
Assert.True(File.Exists(serverAssembly), $"Missing server build output: {serverAssembly}");
|
||||||
|
Assert.True(File.Exists(clientAssembly), $"Missing TestClient build output: {clientAssembly}");
|
||||||
|
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
byte[] signingKey = RandomNumberGenerator.GetBytes(32);
|
||||||
|
string signingKeyText = Convert.ToBase64String(signingKey);
|
||||||
|
string publisherCredential = IssuePublisherCredential(signingKey, now);
|
||||||
|
CryptographicOperations.ZeroMemory(signingKey);
|
||||||
|
Dictionary<string, string> serverEnvironment = ServerEnvironment(
|
||||||
|
signingKeyText,
|
||||||
|
now);
|
||||||
|
|
||||||
|
await using ProcessCapture server = Start(
|
||||||
|
serverAssembly,
|
||||||
|
[],
|
||||||
|
serverEnvironment);
|
||||||
|
string httpLine = await server.WaitForLineAsync("Now listening on: http://127.0.0.1:", timeout.Token);
|
||||||
|
string serviceUrl = ParseServiceUrl(httpLine);
|
||||||
|
string udpLine = await server.WaitForLineAsync("UDP mediator listening on 127.0.0.1:", timeout.Token);
|
||||||
|
int udpPort = ParseTrailingPort(udpLine);
|
||||||
|
await WaitForReadyAsync(serviceUrl, server, timeout.Token);
|
||||||
|
|
||||||
|
string[] emptyBrowseArguments =
|
||||||
|
[
|
||||||
|
"browse",
|
||||||
|
"--service", serviceUrl,
|
||||||
|
"--game", "space-game",
|
||||||
|
"--environment", "integration",
|
||||||
|
"--region", "local",
|
||||||
|
"--protocol", "1",
|
||||||
|
"--script",
|
||||||
|
"--json",
|
||||||
|
"--timeout-seconds", "15",
|
||||||
|
];
|
||||||
|
await using ProcessCapture emptyBrowse = Start(
|
||||||
|
clientAssembly,
|
||||||
|
emptyBrowseArguments,
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||||
|
Assert.Equal(
|
||||||
|
(int)TestClientExitCode.NoCompatibleSession,
|
||||||
|
await emptyBrowse.WaitForExitAsync(timeout.Token));
|
||||||
|
Assert.True(emptyBrowse.HasEvent("browse.completed", "complete"), emptyBrowse.DiagnosticText());
|
||||||
|
Assert.Contains(
|
||||||
|
emptyBrowse.JsonEvents(),
|
||||||
|
item => item.GetProperty("event").GetString() == "browse.completed"
|
||||||
|
&& item.GetProperty("count").GetInt32() == 0);
|
||||||
|
|
||||||
|
string[] missingJoinArguments =
|
||||||
|
[
|
||||||
|
"join",
|
||||||
|
"--service", serviceUrl,
|
||||||
|
"--mediator", $"127.0.0.1:{udpPort}",
|
||||||
|
"--game", "space-game",
|
||||||
|
"--environment", "integration",
|
||||||
|
"--region", "local",
|
||||||
|
"--protocol", "1",
|
||||||
|
"--listing", Guid.NewGuid().ToString("D"),
|
||||||
|
"--script",
|
||||||
|
"--json",
|
||||||
|
"--timeout-seconds", "15",
|
||||||
|
];
|
||||||
|
await using ProcessCapture missingJoin = Start(
|
||||||
|
clientAssembly,
|
||||||
|
missingJoinArguments,
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||||
|
Assert.Equal((int)TestClientExitCode.ServiceFailure, await missingJoin.WaitForExitAsync(timeout.Token));
|
||||||
|
Assert.True(missingJoin.HasEvent("join.selection", "failed"), missingJoin.DiagnosticText());
|
||||||
|
|
||||||
|
string[] boundedHostArguments =
|
||||||
|
[
|
||||||
|
"host",
|
||||||
|
"--service", serviceUrl,
|
||||||
|
"--mediator", $"127.0.0.1:{udpPort}",
|
||||||
|
"--game", "space-game",
|
||||||
|
"--environment", "integration",
|
||||||
|
"--region", "local",
|
||||||
|
"--protocol", "1",
|
||||||
|
"--script",
|
||||||
|
"--json",
|
||||||
|
"--exit-after-echo",
|
||||||
|
"--run-seconds", "1",
|
||||||
|
"--timeout-seconds", "15",
|
||||||
|
];
|
||||||
|
await using ProcessCapture boundedHost = Start(
|
||||||
|
clientAssembly,
|
||||||
|
boundedHostArguments,
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["RENDEZVOUS_PUBLISHER_CREDENTIAL"] = publisherCredential,
|
||||||
|
});
|
||||||
|
Assert.Equal((int)TestClientExitCode.DirectTrafficFailed, await boundedHost.WaitForExitAsync(timeout.Token));
|
||||||
|
Assert.True(boundedHost.HasEvent("host.direct-traffic", "timed-out"), boundedHost.DiagnosticText());
|
||||||
|
Assert.True(boundedHost.HasEvent("host.deregistered", "complete"), boundedHost.DiagnosticText());
|
||||||
|
|
||||||
|
string[] hostArguments =
|
||||||
|
[
|
||||||
|
"host",
|
||||||
|
"--service", serviceUrl,
|
||||||
|
"--mediator", $"127.0.0.1:{udpPort}",
|
||||||
|
"--game", "space-game",
|
||||||
|
"--environment", "integration",
|
||||||
|
"--region", "local",
|
||||||
|
"--protocol", "1",
|
||||||
|
"--script",
|
||||||
|
"--json",
|
||||||
|
"--exit-after-echo",
|
||||||
|
"--timeout-seconds", "15",
|
||||||
|
];
|
||||||
|
await using ProcessCapture host = Start(
|
||||||
|
clientAssembly,
|
||||||
|
hostArguments,
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["RENDEZVOUS_PUBLISHER_CREDENTIAL"] = publisherCredential,
|
||||||
|
});
|
||||||
|
JsonElement hostRegistered = await host.WaitForEventAsync(
|
||||||
|
"host.ready",
|
||||||
|
timeout.Token);
|
||||||
|
string listingId = Assert.IsType<string>(hostRegistered.GetProperty("listingId").GetString());
|
||||||
|
|
||||||
|
string[] browseArguments =
|
||||||
|
[
|
||||||
|
"browse",
|
||||||
|
"--service", serviceUrl,
|
||||||
|
"--game", "space-game",
|
||||||
|
"--environment", "integration",
|
||||||
|
"--region", "local",
|
||||||
|
"--protocol", "1",
|
||||||
|
"--script",
|
||||||
|
"--json",
|
||||||
|
"--timeout-seconds", "15",
|
||||||
|
];
|
||||||
|
await using ProcessCapture browse = Start(
|
||||||
|
clientAssembly,
|
||||||
|
browseArguments,
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||||
|
Assert.Equal(0, await browse.WaitForExitAsync(timeout.Token));
|
||||||
|
Assert.True(browse.HasEvent("browse.completed", "complete"), browse.DiagnosticText());
|
||||||
|
Assert.True(
|
||||||
|
browse.JsonEvents().Any(item =>
|
||||||
|
item.GetProperty("event").GetString() == "browse.session"
|
||||||
|
&& item.GetProperty("status").GetString() == "available"
|
||||||
|
&& item.GetProperty("listingId").GetString() == listingId),
|
||||||
|
browse.DiagnosticText());
|
||||||
|
|
||||||
|
string[] joinArguments =
|
||||||
|
[
|
||||||
|
"join",
|
||||||
|
"--service", serviceUrl,
|
||||||
|
"--mediator", $"127.0.0.1:{udpPort}",
|
||||||
|
"--game", "space-game",
|
||||||
|
"--environment", "integration",
|
||||||
|
"--region", "local",
|
||||||
|
"--protocol", "1",
|
||||||
|
"--listing", listingId,
|
||||||
|
"--script",
|
||||||
|
"--json",
|
||||||
|
"--timeout-seconds", "15",
|
||||||
|
];
|
||||||
|
await using ProcessCapture join = Start(
|
||||||
|
clientAssembly,
|
||||||
|
joinArguments,
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
Assert.Equal(0, await join.WaitForExitAsync(timeout.Token));
|
||||||
|
Assert.Equal(0, await host.WaitForExitAsync(timeout.Token));
|
||||||
|
Assert.True(join.HasEvent("join.connected", "connected"), join.DiagnosticText());
|
||||||
|
Assert.True(join.HasEvent("join.punch", "started"), join.DiagnosticText());
|
||||||
|
Assert.True(join.HasEvent("join.direct-connect", "started"), join.DiagnosticText());
|
||||||
|
Assert.True(join.HasEvent("join.direct-traffic", "verified"), join.DiagnosticText());
|
||||||
|
Assert.True(join.HasEvent("join.outcome-report", "accepted"), join.DiagnosticText());
|
||||||
|
Assert.True(host.HasEvent("host.direct-traffic", "verified"), host.DiagnosticText());
|
||||||
|
Assert.True(host.HasEvent("host.punch", "started"), host.DiagnosticText());
|
||||||
|
Assert.True(host.HasEvent("host.direct-connect", "connected"), host.DiagnosticText());
|
||||||
|
Assert.True(host.HasEvent("host.deregistered", "complete"), host.DiagnosticText());
|
||||||
|
Assert.Equal(host.AllLines().Count(), host.JsonEvents().Count);
|
||||||
|
Assert.Equal(emptyBrowse.AllLines().Count(), emptyBrowse.JsonEvents().Count);
|
||||||
|
Assert.Equal(missingJoin.AllLines().Count(), missingJoin.JsonEvents().Count);
|
||||||
|
Assert.Equal(browse.AllLines().Count(), browse.JsonEvents().Count);
|
||||||
|
Assert.Equal(join.AllLines().Count(), join.JsonEvents().Count);
|
||||||
|
Assert.All(
|
||||||
|
host.JsonEvents()
|
||||||
|
.Concat(emptyBrowse.JsonEvents())
|
||||||
|
.Concat(missingJoin.JsonEvents())
|
||||||
|
.Concat(browse.JsonEvents())
|
||||||
|
.Concat(join.JsonEvents()),
|
||||||
|
AssertAllowlistedEventShape);
|
||||||
|
|
||||||
|
using HttpClient service = new() { BaseAddress = new Uri(serviceUrl) };
|
||||||
|
using HttpResponseMessage removed = await service.GetAsync(
|
||||||
|
$"v1/sessions/{listingId}?contractVersion=1&gameId=space-game&environmentId=integration&protocolVersion=1",
|
||||||
|
timeout.Token);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, removed.StatusCode);
|
||||||
|
|
||||||
|
string captured = string.Join(
|
||||||
|
'\n',
|
||||||
|
server.AllLines()
|
||||||
|
.Concat(boundedHost.AllLines())
|
||||||
|
.Concat(emptyBrowse.AllLines())
|
||||||
|
.Concat(missingJoin.AllLines())
|
||||||
|
.Concat(host.AllLines())
|
||||||
|
.Concat(browse.AllLines())
|
||||||
|
.Concat(join.AllLines()));
|
||||||
|
Assert.False(
|
||||||
|
captured.Contains(publisherCredential, StringComparison.Ordinal),
|
||||||
|
"Captured process output contained the publisher credential.");
|
||||||
|
Assert.False(
|
||||||
|
captured.Contains(signingKeyText, StringComparison.Ordinal),
|
||||||
|
"Captured process output contained signing-key material.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string IssuePublisherCredential(byte[] signingKey, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
const string secretReference = "env:RENDEZVOUS_INTEGRATION_SIGNING_KEY";
|
||||||
|
ProvisioningOptions options = ProvisioningOptions(now, secretReference);
|
||||||
|
using DictionarySecretProvider secrets = new(new Dictionary<string, byte[]>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[secretReference] = signingKey,
|
||||||
|
});
|
||||||
|
using ProvisioningRuntime provisioning = ProvisioningRuntime.Create(options, secrets, now);
|
||||||
|
return provisioning.Credentials.Issue(
|
||||||
|
new DedicatedPublisherPrincipal(
|
||||||
|
"test-client-process-host",
|
||||||
|
now.AddMinutes(5),
|
||||||
|
new GameId("space-game"),
|
||||||
|
new EnvironmentId("integration"),
|
||||||
|
[new RegionId("local")]),
|
||||||
|
now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProvisioningOptions ProvisioningOptions(
|
||||||
|
DateTimeOffset now,
|
||||||
|
string secretReference) => new()
|
||||||
|
{
|
||||||
|
Issuer = "rendezvous-process-test",
|
||||||
|
Audience = "rendezvous-process-test-client",
|
||||||
|
ClockSkewSeconds = 5,
|
||||||
|
SigningKeys =
|
||||||
|
[
|
||||||
|
new SigningKeyOptions
|
||||||
|
{
|
||||||
|
KeyId = "process-test-key",
|
||||||
|
SecretReference = secretReference,
|
||||||
|
CredentialKinds = [PrincipalCredentialKind.DedicatedPublisher],
|
||||||
|
GameId = "space-game",
|
||||||
|
EnvironmentId = "integration",
|
||||||
|
NotBefore = now.AddMinutes(-1),
|
||||||
|
SignUntil = now.AddMinutes(10),
|
||||||
|
VerifyUntil = now.AddMinutes(20),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Games =
|
||||||
|
[
|
||||||
|
new GamePolicyOptions
|
||||||
|
{
|
||||||
|
GameId = "space-game",
|
||||||
|
EnvironmentId = "integration",
|
||||||
|
Enabled = true,
|
||||||
|
ProtocolVersions = [1],
|
||||||
|
Regions = ["local"],
|
||||||
|
VisibilityModes = [ListingVisibility.Public],
|
||||||
|
PublisherTrustModes = [PublisherTrustMode.ManagedDedicated],
|
||||||
|
MetadataMaxBytes = 256,
|
||||||
|
MetadataMaxKeys = 2,
|
||||||
|
MaxListingsPerPrincipal = 4,
|
||||||
|
MaxAnonymousListingsPerAddress = 1,
|
||||||
|
MaxActiveJoinAttempts = 16,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ServerEnvironment(
|
||||||
|
string signingKey,
|
||||||
|
DateTimeOffset now)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> values = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["ASPNETCORE_ENVIRONMENT"] = "Production",
|
||||||
|
["ASPNETCORE_URLS"] = "http://127.0.0.1:0",
|
||||||
|
["Rendezvous__Udp__ListenAddress"] = "127.0.0.1",
|
||||||
|
["Rendezvous__Udp__Port"] = "0",
|
||||||
|
["Rendezvous__Udp__PollIntervalMilliseconds"] = "1",
|
||||||
|
["Rendezvous__Provisioning__Issuer"] = "rendezvous-process-test",
|
||||||
|
["Rendezvous__Provisioning__Audience"] = "rendezvous-process-test-client",
|
||||||
|
["Rendezvous__Provisioning__ClockSkewSeconds"] = "5",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__KeyId"] = "process-test-key",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__SecretReference"] =
|
||||||
|
"env:RENDEZVOUS_INTEGRATION_SIGNING_KEY",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__CredentialKinds__0"] = "DedicatedPublisher",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__GameId"] = "space-game",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__EnvironmentId"] = "integration",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__NotBefore"] = now.AddMinutes(-1).ToString("O"),
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__SignUntil"] = now.AddMinutes(10).ToString("O"),
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__VerifyUntil"] = now.AddMinutes(20).ToString("O"),
|
||||||
|
["Rendezvous__Provisioning__Games__0__GameId"] = "space-game",
|
||||||
|
["Rendezvous__Provisioning__Games__0__EnvironmentId"] = "integration",
|
||||||
|
["Rendezvous__Provisioning__Games__0__Enabled"] = "true",
|
||||||
|
["Rendezvous__Provisioning__Games__0__ProtocolVersions__0"] = "1",
|
||||||
|
["Rendezvous__Provisioning__Games__0__Regions__0"] = "local",
|
||||||
|
["Rendezvous__Provisioning__Games__0__VisibilityModes__0"] = "Public",
|
||||||
|
["Rendezvous__Provisioning__Games__0__PublisherTrustModes__0"] = "ManagedDedicated",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MetadataMaxBytes"] = "256",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MetadataMaxKeys"] = "2",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MaxListingsPerPrincipal"] = "4",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MaxAnonymousListingsPerAddress"] = "1",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MaxActiveJoinAttempts"] = "16",
|
||||||
|
["RENDEZVOUS_INTEGRATION_SIGNING_KEY"] = signingKey,
|
||||||
|
};
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessCapture Start(
|
||||||
|
string assembly,
|
||||||
|
IReadOnlyList<string> arguments,
|
||||||
|
IReadOnlyDictionary<string, string> environment)
|
||||||
|
{
|
||||||
|
ProcessStartInfo start = new()
|
||||||
|
{
|
||||||
|
FileName = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet",
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
start.ArgumentList.Add(assembly);
|
||||||
|
foreach (string argument in arguments)
|
||||||
|
{
|
||||||
|
start.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
foreach (KeyValuePair<string, string> item in environment)
|
||||||
|
{
|
||||||
|
start.Environment[item.Key] = item.Value;
|
||||||
|
}
|
||||||
|
return new ProcessCapture(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForReadyAsync(
|
||||||
|
string serviceUrl,
|
||||||
|
ProcessCapture server,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HttpClient client = new() { BaseAddress = new Uri(serviceUrl) };
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (server.HasExited)
|
||||||
|
{
|
||||||
|
throw new Xunit.Sdk.XunitException($"Server exited before readiness. {server.DiagnosticText()}");
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpResponseMessage response = await client.GetAsync(
|
||||||
|
"health/ready",
|
||||||
|
cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
await Task.Delay(25, cancellationToken);
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ParseServiceUrl(string line)
|
||||||
|
{
|
||||||
|
const string marker = "Now listening on: ";
|
||||||
|
string value = line[(line.IndexOf(marker, StringComparison.Ordinal) + marker.Length)..].Trim();
|
||||||
|
return new Uri(value).AbsoluteUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseTrailingPort(string line)
|
||||||
|
{
|
||||||
|
string value = line[(line.LastIndexOf(':') + 1)..].Trim();
|
||||||
|
return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RepositoryRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
throw new DirectoryNotFoundException("Could not locate the Rendezvous repository root.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertAllowlistedEventShape(JsonElement item)
|
||||||
|
{
|
||||||
|
string[] forbidden = ["credential", "capability", "ticket", "token", "secret", "metadata"];
|
||||||
|
foreach (JsonProperty property in item.EnumerateObject())
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(forbidden, forbiddenName =>
|
||||||
|
property.Name.Contains(forbiddenName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProcessCapture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly Process _process;
|
||||||
|
private readonly ConcurrentQueue<string> _standardOutput = new();
|
||||||
|
private readonly ConcurrentQueue<string> _standardError = new();
|
||||||
|
|
||||||
|
internal ProcessCapture(ProcessStartInfo start)
|
||||||
|
{
|
||||||
|
_process = new Process { StartInfo = start };
|
||||||
|
_process.OutputDataReceived += (_, eventArgs) => Add(eventArgs.Data, _standardOutput);
|
||||||
|
_process.ErrorDataReceived += (_, eventArgs) => Add(eventArgs.Data, _standardError);
|
||||||
|
Assert.True(_process.Start(), $"Failed to start {start.FileName}.");
|
||||||
|
_process.BeginOutputReadLine();
|
||||||
|
_process.BeginErrorReadLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool HasExited => _process.HasExited;
|
||||||
|
|
||||||
|
internal async Task<JsonElement> WaitForEventAsync(
|
||||||
|
string eventName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
foreach (JsonElement item in JsonEvents())
|
||||||
|
{
|
||||||
|
if (item.TryGetProperty("event", out JsonElement eventProperty)
|
||||||
|
&& eventProperty.GetString() == eventName)
|
||||||
|
{
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_process.HasExited)
|
||||||
|
{
|
||||||
|
throw new Xunit.Sdk.XunitException(
|
||||||
|
$"Process exited before event {eventName}. {DiagnosticText()}");
|
||||||
|
}
|
||||||
|
await Task.Delay(10, cancellationToken);
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
throw new UnreachableException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<string> WaitForLineAsync(
|
||||||
|
string marker,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
string? line = AllLines().FirstOrDefault(item =>
|
||||||
|
item.Contains(marker, StringComparison.Ordinal));
|
||||||
|
if (line is not null)
|
||||||
|
{
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (_process.HasExited)
|
||||||
|
{
|
||||||
|
throw new Xunit.Sdk.XunitException(
|
||||||
|
$"Process exited before output marker {marker}. {DiagnosticText()}");
|
||||||
|
}
|
||||||
|
await Task.Delay(10, cancellationToken);
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
throw new UnreachableException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool HasEvent(string eventName, string status) => JsonEvents().Any(item =>
|
||||||
|
item.TryGetProperty("event", out JsonElement eventProperty)
|
||||||
|
&& eventProperty.GetString() == eventName
|
||||||
|
&& item.TryGetProperty("status", out JsonElement statusProperty)
|
||||||
|
&& statusProperty.GetString() == status);
|
||||||
|
|
||||||
|
internal List<JsonElement> JsonEvents()
|
||||||
|
{
|
||||||
|
List<JsonElement> items = [];
|
||||||
|
foreach (string line in _standardOutput.Concat(_standardError))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(line);
|
||||||
|
items.Add(document.RootElement.Clone());
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IEnumerable<string> AllLines() => _standardOutput.Concat(_standardError);
|
||||||
|
|
||||||
|
internal async Task<int> WaitForExitAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _process.WaitForExitAsync(cancellationToken);
|
||||||
|
return _process.ExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string DiagnosticText()
|
||||||
|
{
|
||||||
|
string events = string.Join(
|
||||||
|
',',
|
||||||
|
JsonEvents().Select(static item =>
|
||||||
|
$"{item.GetProperty("event").GetString()}:{item.GetProperty("status").GetString()}"));
|
||||||
|
return $"stdoutLines={_standardOutput.Count}; stderrLines={_standardError.Count}; events=[{events}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
bool cleanupTimedOut = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_process.HasExited)
|
||||||
|
{
|
||||||
|
_process.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5));
|
||||||
|
await _process.WaitForExitAsync(cleanup.Token);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
cleanupTimedOut = !_process.HasExited;
|
||||||
|
if (cleanupTimedOut)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_process.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_process.Dispose();
|
||||||
|
}
|
||||||
|
if (cleanupTimedOut)
|
||||||
|
{
|
||||||
|
throw new Xunit.Sdk.XunitException("Child process did not exit within the cleanup deadline.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Add(string? line, ConcurrentQueue<string> destination)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(line))
|
||||||
|
{
|
||||||
|
destination.Enqueue(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult
|
||||||
|
ENUM Accepted=1
|
||||||
|
ENUM NotFound=2
|
||||||
|
ENUM Expired=3
|
||||||
|
ENUM Rejected=4
|
||||||
|
ENUM AlreadyConsumed=5
|
||||||
|
ENUM Revoked=6
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
|
||||||
|
CTOR (System.Int32 maximumAuthorizedTickets)
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult Consume(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
|
||||||
|
METHOD System.Void Dispose()
|
||||||
|
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
|
||||||
|
METHOD System.String ToString()
|
||||||
|
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequest
|
||||||
|
CTOR ()
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
||||||
|
PROP System.String ConnectionTicket {get;set;}
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequestCodec
|
||||||
|
FIELD System.Int32 EncodedLength=63
|
||||||
|
METHOD System.Byte[] Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
|
||||||
|
METHOD System.Boolean IsRendezvousRequest(System.ReadOnlySpan<System.Byte> encoded)
|
||||||
|
METHOD System.Boolean TryDecode(System.ReadOnlySpan<System.Byte> encoded, FinalFactory.Rendezvous.Client.DirectConnectionRequest& request)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
|
||||||
|
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousJoinClient
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult> CreateConnectionAttemptAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse>> ReportOutcomeAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousSessionBrowserClient
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason
|
||||||
|
ENUM Cancelled=1
|
||||||
|
ENUM Disposed=2
|
||||||
|
ENUM LeaseLost=3
|
||||||
|
ENUM Failed=4
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.PublishedSession
|
||||||
|
PROP System.DateTimeOffset ExpiresAt {get;}
|
||||||
|
PROP System.String HostPresenceCapability {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;}
|
||||||
|
PROP System.Int32 HostPresenceRefreshAfterSeconds {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;}
|
||||||
|
PROP System.Int32 LeaseRenewAfterSeconds {get;}
|
||||||
|
PROP System.String LeaseToken {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;}
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousClientCoordinator
|
||||||
|
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
|
||||||
|
PROP LiteNetLib.NetPeer ConnectedPeer {get;}
|
||||||
|
PROP System.Boolean IsCompleted {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
|
||||||
|
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs> Completed
|
||||||
|
METHOD System.Void Cancel()
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Void Dispose()
|
||||||
|
METHOD System.Void Poll()
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse>> ReportOutcomeAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
|
||||||
|
CTOR ()
|
||||||
|
PROP System.TimeSpan InitialRetryDelay {get;set;}
|
||||||
|
PROP System.Double JitterRatio {get;set;}
|
||||||
|
PROP System.TimeSpan MaximumRetryDelay {get;set;}
|
||||||
|
PROP System.Int32 MaximumSafeRetries {get;set;}
|
||||||
|
PROP System.TimeSpan RequestTimeout {get;set;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable<System.Int32> retryAfterSeconds)
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Success(T value)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||||
|
PROP System.Boolean IsSuccess {get;}
|
||||||
|
PROP System.String Message {get;}
|
||||||
|
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;}
|
||||||
|
PROP T Value {get;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs
|
||||||
|
CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
|
||||||
|
PROP LiteNetLib.NetPeer Peer {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionFailureCategory
|
||||||
|
ENUM None=0
|
||||||
|
ENUM Directory=1
|
||||||
|
ENUM Compatibility=2
|
||||||
|
ENUM Authorization=3
|
||||||
|
ENUM Capacity=4
|
||||||
|
ENUM HostPresence=5
|
||||||
|
ENUM Service=6
|
||||||
|
ENUM Mediation=7
|
||||||
|
ENUM NatTraversal=8
|
||||||
|
ENUM DirectConnection=9
|
||||||
|
ENUM Lifecycle=10
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionFailureCategory Category {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;}
|
||||||
|
PROP System.TimeSpan Elapsed {get;}
|
||||||
|
PROP System.Boolean HasDedicatedFallback {get;}
|
||||||
|
PROP System.Boolean IsSuccess {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Kind {get;}
|
||||||
|
PROP LiteNetLib.NetPeer Peer {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionPhase Phase {get;}
|
||||||
|
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.RendezvousErrorCode> ServiceError {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcomeSource Source {get;}
|
||||||
|
METHOD FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket BucketElapsed(System.TimeSpan elapsed)
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome FromServiceError(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.TimeSpan elapsed, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback)
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionOutcomeSource
|
||||||
|
ENUM RendezvousService=1
|
||||||
|
ENUM LocalTraversal=2
|
||||||
|
ENUM RemoteHost=3
|
||||||
|
ENUM Caller=4
|
||||||
|
ENUM Lifecycle=5
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionPhase
|
||||||
|
ENUM Directory=1
|
||||||
|
ENUM Authorization=2
|
||||||
|
ENUM Mediation=3
|
||||||
|
ENUM NatTraversal=4
|
||||||
|
ENUM DirectConnection=5
|
||||||
|
ENUM Complete=6
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse Attempt {get;}
|
||||||
|
PROP System.Boolean IsCompleted {get;}
|
||||||
|
PROP System.Boolean IsReadyForTraversal {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult Completed(FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome)
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult ReadyForTraversal(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState
|
||||||
|
ENUM Punching=1
|
||||||
|
ENUM Connecting=2
|
||||||
|
ENUM Connected=3
|
||||||
|
ENUM Cancelled=4
|
||||||
|
ENUM TimedOut=5
|
||||||
|
ENUM Rejected=6
|
||||||
|
ENUM ManagerStopped=7
|
||||||
|
ENUM Disposed=8
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions
|
||||||
|
CTOR ()
|
||||||
|
PROP System.TimeSpan ConnectionTicketLifetime {get;set;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallbackOverride {get;set;}
|
||||||
|
PROP System.TimeSpan DirectConnectTimeout {get;set;}
|
||||||
|
PROP System.TimeSpan InitialPunchRetryDelay {get;set;}
|
||||||
|
PROP System.Double JitterRatio {get;set;}
|
||||||
|
PROP System.Int32 MaximumAttemptChecksPerPoll {get;set;}
|
||||||
|
PROP System.Int32 MaximumPunchRequests {get;set;}
|
||||||
|
PROP System.TimeSpan MaximumPunchRetryDelay {get;set;}
|
||||||
|
PROP System.TimeSpan PunchTimeout {get;set;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs
|
||||||
|
CTOR (FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
|
||||||
|
PROP LiteNetLib.NetPeer Peer {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousHostCoordinator
|
||||||
|
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
|
||||||
|
PROP System.Int32 PendingAttemptCount {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousHostState State {get;}
|
||||||
|
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs> AttemptCompleted
|
||||||
|
METHOD System.Void Dispose()
|
||||||
|
METHOD System.Void Poll()
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Int32>> RefreshJoinAttemptsAsync(System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousHostState
|
||||||
|
ENUM Active=1
|
||||||
|
ENUM ManagerStopped=2
|
||||||
|
ENUM Disposed=3
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousJoinClient
|
||||||
|
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult> CreateConnectionAttemptAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse>> ReportOutcomeAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousNetListener
|
||||||
|
CTOR ()
|
||||||
|
PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;}
|
||||||
|
PROP LiteNetLib.EventBasedNatPunchListener PunchEvents {get;}
|
||||||
|
METHOD LiteNetLib.NetManager CreateManager()
|
||||||
|
METHOD System.Void OnConnectionRequest(LiteNetLib.ConnectionRequest request)
|
||||||
|
METHOD System.Void OnMessageDelivered(LiteNetLib.NetPeer peer, System.Object userData)
|
||||||
|
METHOD System.Void OnNetworkError(System.Net.IPEndPoint endPoint, System.Net.Sockets.SocketError socketError)
|
||||||
|
METHOD System.Void OnNetworkLatencyUpdate(LiteNetLib.NetPeer peer, System.Int32 latency)
|
||||||
|
METHOD System.Void OnNetworkReceive(LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, System.Byte channelNumber, LiteNetLib.DeliveryMethod deliveryMethod)
|
||||||
|
METHOD System.Void OnNetworkReceiveUnconnected(System.Net.IPEndPoint remoteEndPoint, LiteNetLib.NetPacketReader reader, LiteNetLib.UnconnectedMessageType messageType)
|
||||||
|
METHOD System.Void OnNtpResponse(LiteNetLib.Utils.NtpPacket packet)
|
||||||
|
METHOD System.Void OnPeerAddressChanged(LiteNetLib.NetPeer peer, System.Net.IPEndPoint previousAddress)
|
||||||
|
METHOD System.Void OnPeerConnected(LiteNetLib.NetPeer peer)
|
||||||
|
METHOD System.Void OnPeerDisconnected(LiteNetLib.NetPeer peer, LiteNetLib.DisconnectInfo disconnectInfo)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient
|
||||||
|
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousSessionBrowserClient
|
||||||
|
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
|
||||||
|
EVENT System.EventHandler LeaseLost
|
||||||
|
METHOD System.Threading.Tasks.ValueTask DisposeAsync()
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.LeaseMaintenanceResult> RunAsync(System.Threading.CancellationToken cancellationToken)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user