Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1baa1055dc | |||
| 06c3973ce7 | |||
| a9a2b3db35 | |||
| 49564c7e7e |
+443
-18
@@ -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,109 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -875,7 +1277,9 @@
|
|||||||
"leaseToken",
|
"leaseToken",
|
||||||
"hostPresenceHandle",
|
"hostPresenceHandle",
|
||||||
"hostPresenceCapability",
|
"hostPresenceCapability",
|
||||||
"expiresAt"
|
"expiresAt",
|
||||||
|
"leaseRenewAfterSeconds",
|
||||||
|
"hostPresenceRefreshAfterSeconds"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -901,6 +1305,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 +1354,8 @@
|
|||||||
"RenewLeaseResponse": {
|
"RenewLeaseResponse": {
|
||||||
"required": [
|
"required": [
|
||||||
"contractVersion",
|
"contractVersion",
|
||||||
"expiresAt"
|
"expiresAt",
|
||||||
|
"renewAfterSeconds"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -953,6 +1366,10 @@
|
|||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"renewAfterSeconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1115,6 +1532,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"securitySchemes": {
|
||||||
|
"PublisherBearer": {
|
||||||
|
"type": "http",
|
||||||
|
"description": "Tenant-scoped publisher credential issued during game provisioning.",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "rv1 publisher credential"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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 |
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ observe the store.
|
|||||||
|
|
||||||
### Concurrency and idempotency
|
### Concurrency and idempotency
|
||||||
|
|
||||||
- Listing registration and join-attempt creation use an owner-scoped 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
|
key plus a canonical request fingerprint. An exact duplicate returns the
|
||||||
original live result; reuse with different input returns `Conflict`; replay
|
original live result; reuse with different input returns `Conflict`; replay
|
||||||
after the resource has expired returns `Expired` until the bounded idempotency
|
after the resource has expired returns `Expired` until the bounded idempotency
|
||||||
record itself expires.
|
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
|
- 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
|
version as `Conflict`. Renew/delete races are serialized: renewal either commits
|
||||||
before deletion or observes the listing as absent.
|
before deletion or observes the listing as absent.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# ADR 0005: authenticated session lease and presence lifecycle
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #7
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A host needs to publish a player-facing session without letting an HTTP request
|
||||||
|
claim a public endpoint or remain visible after the gameplay socket disappears.
|
||||||
|
Registration retries must be safe, credentials must remain opaque, and policy or
|
||||||
|
ownership checks cannot race state mutation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The four host HTTP operations require `Authorization: Bearer <publisher credential>`.
|
||||||
|
The signed principal supplies the authoritative game, environment, publisher trust
|
||||||
|
mode, subject, and allowed regions. Request fields never widen that scope. Creation
|
||||||
|
and update apply the enabled `GamePolicy` to exact protocol, region, visibility,
|
||||||
|
bounded display/build/capacity values, and the allowlisted metadata schema.
|
||||||
|
|
||||||
|
Capacity reported by a host is advisory directory information. Rendezvous bounds
|
||||||
|
and publishes it but never treats it as final admission authority; the game host
|
||||||
|
still decides identity, bans, reserved slots, and whether a connection may join.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> AwaitingPresence: authorized register
|
||||||
|
AwaitingPresence --> Listed: valid host UDP presence
|
||||||
|
Listed --> AwaitingPresence: presence deadline passes
|
||||||
|
AwaitingPresence --> AwaitingPresence: lease renew or data update
|
||||||
|
Listed --> Listed: lease renew, data update, or presence refresh
|
||||||
|
AwaitingPresence --> Removed: lease expiry or delete
|
||||||
|
Listed --> Removed: lease expiry or delete
|
||||||
|
Removed --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
Registration returns a listing ID, lease ID/token, host-presence handle/capability,
|
||||||
|
lease expiry, a 30-second renewal suggestion, and a 10-second presence-refresh
|
||||||
|
suggestion. The authoritative ceilings remain 60 seconds for the lease and 20
|
||||||
|
seconds for presence. Timing suggestions are server-controlled, not client-selected.
|
||||||
|
|
||||||
|
The lease token and presence capability are 256-bit opaque values derived with
|
||||||
|
HMAC-SHA256 from an in-memory per-process secret, a purpose label, the publisher
|
||||||
|
subject, the idempotency key, a canonical request fingerprint, and a random
|
||||||
|
per-registration derivation salt. Opaque IDs use separate purpose labels. Exact
|
||||||
|
retries read the retained non-secret salt and therefore reproduce the original
|
||||||
|
response without retaining plaintext credentials. Once the bounded idempotency
|
||||||
|
record expires, a new salt rotates IDs and capabilities so an old token cannot
|
||||||
|
regain authority. Metadata order is canonicalized before fingerprinting. The store
|
||||||
|
retains the salt and only a second keyed fingerprint of each token. Restart rotates
|
||||||
|
the derivation secret while the matching ephemeral state disappears.
|
||||||
|
|
||||||
|
Renew, update, and delete require both the same publisher subject and the lease
|
||||||
|
capability. Cross-owner or wrong-capability access returns the same not-found shape.
|
||||||
|
Update may change display name, build label, advisory capacity, and metadata only;
|
||||||
|
game, environment, region, protocol, visibility, trust mode, and opaque IDs remain
|
||||||
|
canonical. Delete is idempotent and does not reveal whether another publisher owns
|
||||||
|
the supplied ID.
|
||||||
|
|
||||||
|
### UDP presence
|
||||||
|
|
||||||
|
Only a structurally valid `HostPresence` datagram with the issued capability can
|
||||||
|
refresh presence. The public endpoint is the UDP packet's observed source on the
|
||||||
|
host's gameplay socket; the HTTP API never accepts one. The bounded local candidate
|
||||||
|
comes from the authenticated datagram. Invalid, unknown, or client-presence packets
|
||||||
|
receive no response. Presence expiry demotes public visibility but keeps the lease,
|
||||||
|
so the same handle can restore visibility without changing session identity.
|
||||||
|
|
||||||
|
Public listing responses contain bounded listing data only. They never contain
|
||||||
|
public/local endpoints, lease tokens, presence capabilities, fingerprints, store
|
||||||
|
keys, or canonical player identity.
|
||||||
|
|
||||||
|
## Failure semantics
|
||||||
|
|
||||||
|
- malformed or policy-invalid fields return a stable typed `InvalidRequest`;
|
||||||
|
- an unsupported gameplay protocol returns `IncompatibleProtocol`;
|
||||||
|
- missing/invalid publisher authentication returns `AuthenticationRequired`;
|
||||||
|
- cross-scope authorization returns `Forbidden` without resource disclosure;
|
||||||
|
- wrong owner/capability or expired state returns the tenant-hidden `NotFound`;
|
||||||
|
- idempotency reuse with changed input returns `Conflict`;
|
||||||
|
- publisher/global exhaustion returns `CapacityExceeded`; and
|
||||||
|
- drain or loss of atomic state returns `ServiceUnavailable` and authorizes no join.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- HTTP registration alone can never make a public session browseable.
|
||||||
|
- Plaintext session capabilities are returned to the intended host but are not
|
||||||
|
retained, logged, included in public listing DTOs, or exported as metrics.
|
||||||
|
- Re-registration after restart is the recovery path; there is no durable session
|
||||||
|
identity or gameplay state in Rendezvous.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# ADR 0006: bounded compatible session browser
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #8
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The public list endpoint requires game, environment, and exact gameplay protocol.
|
||||||
|
Region is optional, page size is 1–100, and callers may exclude sessions whose
|
||||||
|
advisory current-player count has reached the advertised maximum. Lists contain
|
||||||
|
public sessions only and only while both lease and authenticated host presence are
|
||||||
|
fresh. Unlisted sessions never appear in a list; they may be retrieved directly by
|
||||||
|
their 128-bit unguessable listing ID only when the caller also supplies the exact
|
||||||
|
game, environment, and protocol scope.
|
||||||
|
|
||||||
|
Results use ascending opaque listing ID as a deterministic keyset. A cursor carries
|
||||||
|
the last ID plus every compatibility/filter field, a five-minute expiry, and an
|
||||||
|
HMAC-SHA256 signature under a per-process key. Tampering, expiry, or reuse with a
|
||||||
|
different tenant/protocol/region/full filter returns `InvalidRequest`. Restart
|
||||||
|
rotates the key, matching the loss of ephemeral listings.
|
||||||
|
|
||||||
|
Pagination is a bounded live view, not a database snapshot. A record that remains
|
||||||
|
eligible and whose ID is greater than the cursor is returned exactly once. Records
|
||||||
|
removed or made stale disappear immediately. A record created after a page whose ID
|
||||||
|
sorts before that page's cursor is outside that traversal; callers refresh from the
|
||||||
|
first page to discover new sessions. This avoids skips or duplicates among stable
|
||||||
|
eligible records without retaining per-browser snapshot state.
|
||||||
|
|
||||||
|
The store reads at most page size plus one record. The service serializes against
|
||||||
|
the 256 KiB response ceiling and shortens a page before returning it when metadata
|
||||||
|
makes the requested count too large. A continuation cursor is emitted whenever an
|
||||||
|
extra or byte-trimmed record remains. All cursor, page, metadata, property, scalar,
|
||||||
|
and collection sizes are bounded before untrusted allocation can grow without a
|
||||||
|
ceiling.
|
||||||
|
|
||||||
|
Browser DTOs are fresh copies containing only opaque listing ID, exact compatibility,
|
||||||
|
region, visibility/trust presentation, advisory capacity, build/display labels, and
|
||||||
|
policy-validated string metadata. They contain no observed endpoint, lease,
|
||||||
|
capability, ticket, credential fingerprint, derivation salt, principal subject, or
|
||||||
|
store key. Metadata is display text: JSON encoding escapes markup, but game UI must
|
||||||
|
still render values as text and must never execute markup, interpret endpoints, or
|
||||||
|
use metadata for authorization.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Cross-game, cross-environment, incompatible, stale, revoked, expired, unlisted,
|
||||||
|
and optionally full sessions are removed before response construction.
|
||||||
|
- Direct unlisted lookup is suitable for an out-of-band invite carrying the opaque
|
||||||
|
ID; human join codes remain future work and require their own bounded abuse model.
|
||||||
|
- Host capacity remains advisory. The host makes the final admission decision.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR 0007: caller-owned .NET publisher and browser SDK
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #9
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The .NET client package exposes separate publisher and browser interfaces plus
|
||||||
|
concrete clients over a caller-supplied `HttpClient`. The caller owns that client,
|
||||||
|
its handler, base address, connection pool, proxy, and lifetime. SDK operations
|
||||||
|
dispose every request, response, and response body they create, but never dispose
|
||||||
|
the supplied client. The package targets `netstandard2.1`, depends only on the
|
||||||
|
wire-contract package and LiteNetLib, and contains no Godot types, global client,
|
||||||
|
service URL, publisher secret, or embedded game credential.
|
||||||
|
|
||||||
|
Every operation returns `RendezvousClientResult<T>` with a stable error code,
|
||||||
|
message, and optional retry guidance. Cancellation remains exceptional through
|
||||||
|
the caller's `CancellationToken`; transport failures become `ServiceUnavailable`.
|
||||||
|
Response bodies are streamed under the contract's 256 KiB browser ceiling before
|
||||||
|
deserialization. Invalid or oversized success bodies become `InternalError` and
|
||||||
|
never escape as partially trusted contract objects.
|
||||||
|
|
||||||
|
The SDK retries only operations whose duplicate execution is safe: scoped reads,
|
||||||
|
idempotency-keyed registration, lease renewal with the same lease token, complete
|
||||||
|
resource update, and lease-token deregistration. It honors bounded server retry
|
||||||
|
guidance and otherwise uses capped exponential backoff with jitter. Each retry
|
||||||
|
creates a fresh HTTP request while preserving the caller's registration
|
||||||
|
idempotency key. Configuration is copied on construction so later option mutation
|
||||||
|
cannot change an in-flight client's behavior.
|
||||||
|
|
||||||
|
`PublishedSession` holds the server-issued lease and presence capabilities needed
|
||||||
|
by the host. Its string representation always redacts them. Update requests are
|
||||||
|
copied before the lease token is attached, so the SDK never mutates caller-owned
|
||||||
|
DTOs. The browser exposes one-page calls and bounded cursor traversal; cursor
|
||||||
|
values remain opaque and caller requests remain unchanged.
|
||||||
|
|
||||||
|
Lease maintenance is explicit. Creating a `SessionLeaseMaintainer` starts no task;
|
||||||
|
the game chooses when to call `RunAsync`, owns cancellation, and awaits
|
||||||
|
`DisposeAsync`. The loop uses the latest server-provided renewal interval and
|
||||||
|
returns a distinct cancelled, disposed, lost-lease, or failed result. Terminal
|
||||||
|
authorization, expiry, and missing-lease responses also raise `LeaseLost` so the
|
||||||
|
host can stop advertising or re-register deliberately.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- SpaceGame and Unscouted can inject the publisher/browser interfaces in tests
|
||||||
|
without an engine runtime or real network.
|
||||||
|
- Games must configure an absolute `HttpClient.BaseAddress` (or equivalent
|
||||||
|
handler routing), obtain publisher credentials from their deployment boundary,
|
||||||
|
and explicitly run and dispose lease maintenance.
|
||||||
|
- The versioned client public-API snapshot and live-server integration tests fail
|
||||||
|
together when SDK and HTTP contracts drift.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# ADR 0008: scoped join attempts and one-time connection tickets
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #10
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Join creation is an unauthenticated public operation because v1 does not treat a
|
||||||
|
Rendezvous caller as game identity. The HTTP source address is normalized and
|
||||||
|
converted to a process-keyed opaque subject for idempotency and bounded policy
|
||||||
|
accounting; raw addresses and the derived subject are never returned or logged.
|
||||||
|
A successful request means only that this network client may try to connect to
|
||||||
|
this active session. It does not reserve capacity or grant gameplay admission.
|
||||||
|
|
||||||
|
Creation validates the v1 contract, caller idempotency key, enabled tenant policy,
|
||||||
|
exact gameplay protocol, listing scope, live lease, and fresh authenticated host
|
||||||
|
presence in one atomic store operation. A listing advertised as full remains
|
||||||
|
joinable because its player count is advisory and the game host owns the final
|
||||||
|
capacity, identity, ban, and admission decision.
|
||||||
|
|
||||||
|
Each attempt derives independent host-punch, client-punch, and connection-ticket
|
||||||
|
credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC
|
||||||
|
key, the client subject, the complete canonical request fingerprint, a fresh salt,
|
||||||
|
and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
|
||||||
|
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
|
||||||
|
256-character NAT token ceiling. State retains keyed credential fingerprints,
|
||||||
|
derivation inputs, and salt—not issued plaintext. All diagnostic string
|
||||||
|
representations redact credentials and derivation material.
|
||||||
|
|
||||||
|
The client receives only its punch capability. A host polls its own listing with
|
||||||
|
the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
|
||||||
|
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
|
||||||
|
identical join request returns the same live attempt; changing the request under
|
||||||
|
the same owner/key conflicts. A client may cancel with its punch capability in
|
||||||
|
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the
|
||||||
|
attempt. Listing deletion, expiry, revocation, or process restart removes every
|
||||||
|
associated attempt and credential fingerprint.
|
||||||
|
|
||||||
|
Endpoint binding remains role- and capability-specific. The first endpoint
|
||||||
|
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
|
||||||
|
endpoint or role substitution is rejected. An introduction is consumable once
|
||||||
|
only after both roles bind, so concurrent attempts for the same listing cannot
|
||||||
|
cross-wire.
|
||||||
|
|
||||||
|
The connection ticket is distinct from both punch capabilities and is reproduced
|
||||||
|
only after introduction succeeds. Its window begins at that moment and lasts at
|
||||||
|
most 20 seconds without outliving the 30-second attempt. The server has an atomic
|
||||||
|
fingerprint-consumption seam for mediator tests and revocation. On the game host,
|
||||||
|
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
|
||||||
|
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/
|
||||||
|
revoked/replayed tickets, and zeroes retained digests and key material on disposal.
|
||||||
|
Issue #11 carries the ticket in the authenticated introduction; issue #12 wires
|
||||||
|
authorization and consumption into the caller-owned LiteNetLib coordinator.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A join attempt is transport authorization, never proof of player identity or a
|
||||||
|
game slot.
|
||||||
|
- Network-address-derived subjects are process-local abuse/idempotency scopes,
|
||||||
|
not stable user identifiers; stronger authenticated player scopes require a
|
||||||
|
future game-owned identity contract.
|
||||||
|
- Cancellation after a ticket has reached a host must also revoke that host's
|
||||||
|
local validator entry; coordinator wiring owns that race in issue #12.
|
||||||
|
- Capability and ticket plaintext never enter browser results, state snapshots,
|
||||||
|
logs, metrics, or generated string representations.
|
||||||
@@ -7,6 +7,10 @@ decision requires a superseding ADR and corresponding contract/test updates.
|
|||||||
- [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 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md)
|
||||||
|
- [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md)
|
||||||
|
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md)
|
||||||
|
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
|
||||||
|
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
|
||||||
- [Threat model](../security/threat-model.md)
|
- [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,6 +33,7 @@ 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. |
|
||||||
@@ -49,6 +50,11 @@ 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.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -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,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# FinalFactory.Rendezvous.Client
|
||||||
|
|
||||||
|
Godot-independent .NET publisher and session-browser SDK for Rendezvous v1.
|
||||||
|
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
using HttpClient http = new()
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://rendezvous.example/"),
|
||||||
|
};
|
||||||
|
|
||||||
|
string publisherCredential = Environment.GetEnvironmentVariable(
|
||||||
|
"RENDEZVOUS_PUBLISHER_CREDENTIAL")
|
||||||
|
?? throw new InvalidOperationException("Publisher credential is not configured.");
|
||||||
|
CancellationToken cancellationToken = default;
|
||||||
|
RendezvousPublisherClient publisher = new(http);
|
||||||
|
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||||
|
new RegisterSessionRequest
|
||||||
|
{
|
||||||
|
IdempotencyKey = Guid.NewGuid().ToString("N"),
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "My server",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||||
|
},
|
||||||
|
publisherCredential,
|
||||||
|
cancellationToken);
|
||||||
|
if (!registered.IsSuccess || registered.Value is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Registration failed: {registered.Error} ({registered.Message})");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Load `publisherCredential` from the game's deployment secret boundary; never
|
||||||
|
embed it in a client build or source control. A successful registration returns a
|
||||||
|
`PublishedSession` containing the lease and host-presence capabilities.
|
||||||
|
|
||||||
|
Lease renewal is explicit and caller-controlled:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
PublishedSession session = registered.Value;
|
||||||
|
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||||
|
session,
|
||||||
|
publisherCredential);
|
||||||
|
LeaseMaintenanceResult stopped = await maintainer.RunAsync(cancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
Creating the maintainer does not start background work. Await its run and dispose
|
||||||
|
it when hosting stops. Use `IRendezvousPublisherClient` and
|
||||||
|
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
|
||||||
|
the requests and responses it creates but never disposes the supplied `HttpClient`.
|
||||||
|
|
||||||
|
The host-side `ConnectionTicketValidator` is a bounded, thread-safe one-time gate.
|
||||||
|
Authorize only tickets delivered by the authenticated Rendezvous introduction,
|
||||||
|
then consume the exact ticket presented by the direct LiteNetLib connection:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ConnectionTicketValidator tickets = new();
|
||||||
|
tickets.TryAuthorize(attemptId, expectedTicket, expiresAt);
|
||||||
|
ConnectionTicketConsumptionResult admission = tickets.Consume(
|
||||||
|
attemptId,
|
||||||
|
presentedTicket);
|
||||||
|
```
|
||||||
|
|
||||||
|
An `Accepted` ticket authorizes only this connection attempt. The game must still
|
||||||
|
apply its own player identity, capacity, ban, and gameplay admission rules. Revoke
|
||||||
|
the attempt on cancellation and dispose the validator during host shutdown so its
|
||||||
|
keyed ticket digests are zeroed.
|
||||||
|
|
||||||
|
See the repository's ADR 0007 for HTTP ownership/retry semantics and ADR 0008 for
|
||||||
|
join-capability and connection-ticket security semantics.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientResult<T>
|
||||||
|
{
|
||||||
|
internal RendezvousClientResult(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
T? value,
|
||||||
|
string message,
|
||||||
|
int? retryAfterSeconds)
|
||||||
|
{
|
||||||
|
Error = error;
|
||||||
|
Value = value;
|
||||||
|
Message = message;
|
||||||
|
RetryAfterSeconds = retryAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsSuccess => Error == RendezvousErrorCode.None;
|
||||||
|
public RendezvousErrorCode Error { get; }
|
||||||
|
public T? Value { get; }
|
||||||
|
public string Message { get; }
|
||||||
|
public int? RetryAfterSeconds { get; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RendezvousClientResult
|
||||||
|
{
|
||||||
|
public static RendezvousClientResult<T> Success<T>(T value) =>
|
||||||
|
value is null
|
||||||
|
? throw new ArgumentNullException(nameof(value))
|
||||||
|
: new(RendezvousErrorCode.None, value, string.Empty, null);
|
||||||
|
|
||||||
|
public static RendezvousClientResult<T> Failure<T>(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
string message,
|
||||||
|
int? retryAfterSeconds = null) =>
|
||||||
|
error == RendezvousErrorCode.None
|
||||||
|
? throw new ArgumentException("A failure requires a non-success error.", nameof(error))
|
||||||
|
: new(error, default, message ?? string.Empty, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PublishedSession
|
||||||
|
{
|
||||||
|
internal PublishedSession(RegisterSessionResponse response)
|
||||||
|
{
|
||||||
|
ListingId = response.ListingId;
|
||||||
|
LeaseId = response.LeaseId;
|
||||||
|
LeaseToken = response.LeaseToken;
|
||||||
|
HostPresenceHandle = response.HostPresenceHandle;
|
||||||
|
HostPresenceCapability = response.HostPresenceCapability;
|
||||||
|
ExpiresAt = response.ExpiresAt;
|
||||||
|
LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||||
|
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionListingId ListingId { get; }
|
||||||
|
public LeaseId LeaseId { get; }
|
||||||
|
public string LeaseToken { get; }
|
||||||
|
public MediationHandle HostPresenceHandle { get; }
|
||||||
|
public string HostPresenceCapability { get; }
|
||||||
|
public DateTimeOffset ExpiresAt { get; internal set; }
|
||||||
|
public int LeaseRenewAfterSeconds { get; internal set; }
|
||||||
|
public int HostPresenceRefreshAfterSeconds { get; }
|
||||||
|
|
||||||
|
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousPublisherClient
|
||||||
|
{
|
||||||
|
Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||||
|
RegisterSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
UpdateSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousSessionBrowserClient
|
||||||
|
{
|
||||||
|
Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||||
|
SessionListingId listingId,
|
||||||
|
GameId gameId,
|
||||||
|
EnvironmentId environmentId,
|
||||||
|
uint protocolVersion,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousDelay
|
||||||
|
{
|
||||||
|
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousClientOptions
|
||||||
|
{
|
||||||
|
public int MaximumSafeRetries { get; set; } = 2;
|
||||||
|
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||||
|
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||||
|
public double JitterRatio { get; set; } = 0.2;
|
||||||
|
|
||||||
|
internal void Validate()
|
||||||
|
{
|
||||||
|
if (MaximumSafeRetries is < 0 or > 5
|
||||||
|
|| InitialRetryDelay < TimeSpan.Zero
|
||||||
|
|| MaximumRetryDelay < InitialRetryDelay
|
||||||
|
|| MaximumRetryDelay > TimeSpan.FromSeconds(30)
|
||||||
|
|| JitterRatio is < 0 or > 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(RendezvousClientOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemRendezvousDelay : IRendezvousDelay
|
||||||
|
{
|
||||||
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
|
||||||
|
Task.Delay(delay, cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
internal sealed class RendezvousHttpTransport
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly RendezvousClientOptions _options;
|
||||||
|
private readonly IRendezvousDelay _delay;
|
||||||
|
|
||||||
|
internal RendezvousHttpTransport(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options,
|
||||||
|
IRendezvousDelay? delay)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||||
|
RendezvousClientOptions suppliedOptions = options ?? new RendezvousClientOptions();
|
||||||
|
suppliedOptions.Validate();
|
||||||
|
_options = new RendezvousClientOptions
|
||||||
|
{
|
||||||
|
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
|
||||||
|
InitialRetryDelay = suppliedOptions.InitialRetryDelay,
|
||||||
|
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
|
||||||
|
JitterRatio = suppliedOptions.JitterRatio,
|
||||||
|
};
|
||||||
|
_delay = delay ?? new SystemRendezvousDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<RendezvousClientResult<T>> SendSafeAsync<T>(
|
||||||
|
Func<HttpRequestMessage> requestFactory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (int attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpRequestMessage request = requestFactory();
|
||||||
|
using HttpResponseMessage response = await _httpClient
|
||||||
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
if (typeof(T) == typeof(bool) && response.StatusCode == HttpStatusCode.NoContent)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Success((T)(object)true);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<T>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an oversized success response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
T? value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
value = JsonSerializer.Deserialize<T>(payload, ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value is null
|
||||||
|
? RendezvousClientResult.Failure<T>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an invalid success response.")
|
||||||
|
: RendezvousClientResult.Success(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiError error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||||
|
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||||
|
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
|
||||||
|
{
|
||||||
|
await _delay.DelayAsync(
|
||||||
|
GetRetryDelay(attempt, retryAfter),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Failure<T>(error.Code, error.Message, retryAfter);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
IsTransientTransportFailure(exception, cancellationToken)
|
||||||
|
&& attempt < _options.MaximumSafeRetries)
|
||||||
|
{
|
||||||
|
await _delay.DelayAsync(GetRetryDelay(attempt, null), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<T>(
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
"The Rendezvous service did not return a valid response.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static HttpRequestMessage JsonRequest<T>(
|
||||||
|
HttpMethod method,
|
||||||
|
string uri,
|
||||||
|
T body,
|
||||||
|
string? publisherCredential = null)
|
||||||
|
{
|
||||||
|
HttpRequestMessage request = new(method, uri)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(body, ContractJson.Options),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"),
|
||||||
|
};
|
||||||
|
if (publisherCredential is not null)
|
||||||
|
{
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||||
|
"Bearer",
|
||||||
|
RequireCredential(publisherCredential));
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string RequireCredential(string credential) =>
|
||||||
|
!string.IsNullOrWhiteSpace(credential)
|
||||||
|
? credential
|
||||||
|
: throw new ArgumentException("A publisher credential is required.", nameof(credential));
|
||||||
|
|
||||||
|
private static async Task<ApiError> ReadErrorAsync(
|
||||||
|
HttpResponseMessage response,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ApiError? error = JsonSerializer.Deserialize<ApiError>(payload, ContractJson.Options);
|
||||||
|
return error is not null && error.Code != RendezvousErrorCode.None
|
||||||
|
? error
|
||||||
|
: FallbackError(response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is JsonException or InvalidDataException)
|
||||||
|
{
|
||||||
|
return FallbackError(response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> ReadBoundedAsync(
|
||||||
|
HttpContent content,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using Stream source = await content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||||
|
using MemoryStream destination = new();
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int read = await source.ReadAsync(buffer.AsMemory(), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
return destination.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.Length + read > ContractLimits.BrowserResponseMaxBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("The service response exceeded the SDK limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSpan GetRetryDelay(int attempt, int? retryAfterSeconds)
|
||||||
|
{
|
||||||
|
TimeSpan basis = retryAfterSeconds.HasValue
|
||||||
|
? TimeSpan.FromSeconds(Math.Max(0, retryAfterSeconds.Value))
|
||||||
|
: TimeSpan.FromMilliseconds(
|
||||||
|
_options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt));
|
||||||
|
double bounded = Math.Min(basis.TotalMilliseconds, _options.MaximumRetryDelay.TotalMilliseconds);
|
||||||
|
if (_options.JitterRatio == 0 || bounded == 0)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromMilliseconds(bounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] random = new byte[1];
|
||||||
|
RandomNumberGenerator.Fill(random);
|
||||||
|
double unit = random[0] / 255d;
|
||||||
|
double multiplier = 1 - _options.JitterRatio + (2 * _options.JitterRatio * unit);
|
||||||
|
return TimeSpan.FromMilliseconds(Math.Min(
|
||||||
|
bounded * multiplier,
|
||||||
|
_options.MaximumRetryDelay.TotalMilliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTransient(RendezvousErrorCode code) => code is
|
||||||
|
RendezvousErrorCode.RateLimited
|
||||||
|
or RendezvousErrorCode.CapacityExceeded
|
||||||
|
or RendezvousErrorCode.ServiceUnavailable;
|
||||||
|
|
||||||
|
private static bool IsTransientTransportFailure(
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken callerCancellation) =>
|
||||||
|
exception is HttpRequestException
|
||||||
|
or IOException
|
||||||
|
|| exception is OperationCanceledException && !callerCancellation.IsCancellationRequested;
|
||||||
|
|
||||||
|
private static int? GetRetryAfterSeconds(RetryConditionHeaderValue? retryAfter) =>
|
||||||
|
retryAfter?.Delta is TimeSpan delta
|
||||||
|
? Math.Max(0, (int)Math.Ceiling(delta.TotalSeconds))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static ApiError FallbackError(HttpStatusCode statusCode) => new()
|
||||||
|
{
|
||||||
|
Code = statusCode switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.BadRequest => RendezvousErrorCode.InvalidRequest,
|
||||||
|
HttpStatusCode.Unauthorized => RendezvousErrorCode.AuthenticationRequired,
|
||||||
|
HttpStatusCode.Forbidden => RendezvousErrorCode.Forbidden,
|
||||||
|
HttpStatusCode.NotFound => RendezvousErrorCode.NotFound,
|
||||||
|
HttpStatusCode.Conflict => RendezvousErrorCode.Conflict,
|
||||||
|
HttpStatusCode.Gone => RendezvousErrorCode.Expired,
|
||||||
|
HttpStatusCode.TooManyRequests => RendezvousErrorCode.RateLimited,
|
||||||
|
HttpStatusCode.RequestTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.BadGateway => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.GatewayTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
_ => RendezvousErrorCode.InternalError,
|
||||||
|
},
|
||||||
|
Message = "The service returned an error without a valid Rendezvous envelope.",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
||||||
|
{
|
||||||
|
private readonly RendezvousHttpTransport _transport;
|
||||||
|
private readonly IRendezvousDelay _delay;
|
||||||
|
|
||||||
|
public RendezvousPublisherClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options = null,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_delay = delay ?? new SystemRendezvousDelay();
|
||||||
|
_transport = new(httpClient, options, _delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||||
|
RegisterSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterSessionRequest body = CopyRegistration(request);
|
||||||
|
RendezvousClientResult<RegisterSessionResponse> result = await _transport.SendSafeAsync<RegisterSessionResponse>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/sessions", body, publisherCredential),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return result.IsSuccess && result.Value is not null
|
||||||
|
? RendezvousClientResult.Success(new PublishedSession(result.Value))
|
||||||
|
: RendezvousClientResult.Failure<PublishedSession>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousClientResult<RenewLeaseResponse> result = await _transport.SendSafeAsync<RenewLeaseResponse>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"v1/sessions/{session.ListingId}/renew",
|
||||||
|
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
|
||||||
|
publisherCredential),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (result.IsSuccess && result.Value is not null)
|
||||||
|
{
|
||||||
|
session.ExpiresAt = result.Value.ExpiresAt;
|
||||||
|
session.LeaseRenewAfterSeconds = result.Value.RenewAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
UpdateSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateSessionRequest body = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
LeaseToken = session.LeaseToken,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Capacity = CopyCapacity(request.Capacity),
|
||||||
|
Metadata = CopyMetadata(request.Metadata),
|
||||||
|
};
|
||||||
|
return _transport.SendSafeAsync<bool>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(
|
||||||
|
HttpMethod.Put,
|
||||||
|
$"v1/sessions/{session.ListingId}",
|
||||||
|
body,
|
||||||
|
publisherCredential),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
return _transport.SendSafeAsync<bool>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/sessions/{session.ListingId}",
|
||||||
|
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
|
||||||
|
publisherCredential),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionLeaseMaintainer CreateLeaseMaintainer(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential) => new(
|
||||||
|
this,
|
||||||
|
session ?? throw new ArgumentNullException(nameof(session)),
|
||||||
|
RendezvousHttpTransport.RequireCredential(publisherCredential),
|
||||||
|
_delay);
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CopyRegistration(RegisterSessionRequest request) => new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Visibility = request.Visibility,
|
||||||
|
Capacity = CopyCapacity(request.Capacity),
|
||||||
|
Metadata = CopyMetadata(request.Metadata),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new()
|
||||||
|
{
|
||||||
|
CurrentPlayers = capacity.CurrentPlayers,
|
||||||
|
MaximumPlayers = capacity.MaximumPlayers,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) =>
|
||||||
|
new(metadata, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserClient
|
||||||
|
{
|
||||||
|
private readonly RendezvousHttpTransport _transport;
|
||||||
|
|
||||||
|
public RendezvousSessionBrowserClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options = null,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_transport = new(httpClient, options, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
string query = $"v1/sessions?contractVersion={request.ContractVersion}"
|
||||||
|
+ $"&gameId={Escape(request.GameId.Value)}"
|
||||||
|
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
|
||||||
|
+ $"&protocolVersion={request.ProtocolVersion}"
|
||||||
|
+ $"&pageSize={request.PageSize}"
|
||||||
|
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
|
||||||
|
+ (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty)
|
||||||
|
+ (request.Cursor is not null ? $"&cursor={Escape(request.Cursor)}" : string.Empty);
|
||||||
|
return _transport.SendSafeAsync<BrowseSessionsResponse>(
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maximumPages is < 1 or > 1000)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maximumPages));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SessionListing> items = [];
|
||||||
|
string? cursor = request.Cursor;
|
||||||
|
for (int page = 0; page < maximumPages; page++)
|
||||||
|
{
|
||||||
|
BrowseSessionsRequest pageRequest = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
PageSize = request.PageSize,
|
||||||
|
ExcludeFull = request.ExcludeFull,
|
||||||
|
Cursor = cursor,
|
||||||
|
};
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> result = await BrowseAsync(
|
||||||
|
pageRequest,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.AddRange(result.Value.Items);
|
||||||
|
cursor = result.Value.NextCursor;
|
||||||
|
if (string.IsNullOrEmpty(cursor))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Success<IReadOnlyList<SessionListing>>(items.AsReadOnly());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
$"Browsing exceeded the configured {maximumPages}-page limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||||
|
SessionListingId listingId,
|
||||||
|
GameId gameId,
|
||||||
|
EnvironmentId environmentId,
|
||||||
|
uint protocolVersion,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string query = $"v1/sessions/{listingId}?contractVersion={ContractLimits.ContractVersion}"
|
||||||
|
+ $"&gameId={Escape(gameId.Value)}"
|
||||||
|
+ $"&environmentId={Escape(environmentId.Value)}"
|
||||||
|
+ $"&protocolVersion={protocolVersion}";
|
||||||
|
return _transport.SendSafeAsync<GetSessionResponse>(
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum LeaseMaintenanceStopReason
|
||||||
|
{
|
||||||
|
Cancelled = 1,
|
||||||
|
Disposed = 2,
|
||||||
|
LeaseLost = 3,
|
||||||
|
Failed = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LeaseMaintenanceResult
|
||||||
|
{
|
||||||
|
internal LeaseMaintenanceResult(LeaseMaintenanceStopReason reason, RendezvousErrorCode error)
|
||||||
|
{
|
||||||
|
Reason = reason;
|
||||||
|
Error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeaseMaintenanceStopReason Reason { get; }
|
||||||
|
public RendezvousErrorCode Error { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SessionLeaseMaintainer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly IRendezvousPublisherClient _publisher;
|
||||||
|
private readonly PublishedSession _session;
|
||||||
|
private readonly string _publisherCredential;
|
||||||
|
private readonly IRendezvousDelay _delay;
|
||||||
|
private readonly CancellationTokenSource _disposeCancellation = new();
|
||||||
|
private Task<LeaseMaintenanceResult>? _activeRun;
|
||||||
|
private Task? _disposeTask;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal SessionLeaseMaintainer(
|
||||||
|
IRendezvousPublisherClient publisher,
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_publisher = publisher;
|
||||||
|
_session = session;
|
||||||
|
_publisherCredential = publisherCredential;
|
||||||
|
_delay = delay ?? new SystemRendezvousDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? LeaseLost;
|
||||||
|
|
||||||
|
public Task<LeaseMaintenanceResult> RunAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(SessionLeaseMaintainer));
|
||||||
|
}
|
||||||
|
if (_activeRun is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Lease maintenance is already running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeRun = RunCoreAsync(cancellationToken);
|
||||||
|
return _activeRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposeTask is not null)
|
||||||
|
{
|
||||||
|
return new(_disposeTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_disposeCancellation.Cancel();
|
||||||
|
_disposeTask = FinishDisposeAsync(_activeRun);
|
||||||
|
return new(_disposeTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinishDisposeAsync(Task<LeaseMaintenanceResult>? active)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (active is not null)
|
||||||
|
{
|
||||||
|
await active.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_disposeCancellation.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LeaseMaintenanceResult> RunCoreAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken,
|
||||||
|
_disposeCancellation.Token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
await _delay.DelayAsync(
|
||||||
|
TimeSpan.FromSeconds(Math.Max(1, _session.LeaseRenewAfterSeconds)),
|
||||||
|
linked.Token).ConfigureAwait(false);
|
||||||
|
RendezvousClientResult<RenewLeaseResponse> renewed = await _publisher.RenewAsync(
|
||||||
|
_session,
|
||||||
|
_publisherCredential,
|
||||||
|
linked.Token).ConfigureAwait(false);
|
||||||
|
if (renewed.IsSuccess)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renewed.Error is RendezvousErrorCode.NotFound
|
||||||
|
or RendezvousErrorCode.Expired
|
||||||
|
or RendezvousErrorCode.Forbidden
|
||||||
|
or RendezvousErrorCode.AuthenticationRequired)
|
||||||
|
{
|
||||||
|
LeaseLost?.Invoke(this, EventArgs.Empty);
|
||||||
|
return new(LeaseMaintenanceStopReason.LeaseLost, renewed.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(LeaseMaintenanceStopReason.Failed, renewed.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (linked.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return new(
|
||||||
|
_disposeCancellation.IsCancellationRequested
|
||||||
|
? LeaseMaintenanceStopReason.Disposed
|
||||||
|
: LeaseMaintenanceStopReason.Cancelled,
|
||||||
|
RendezvousErrorCode.None);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_activeRun = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,10 +46,12 @@ public static class ContractValidation
|
|||||||
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
|
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -99,6 +99,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 +123,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
|
||||||
@@ -165,6 +174,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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed class EphemeralCursorProtector : IDisposable
|
||||||
|
{
|
||||||
|
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public string Protect(string prefix, ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
string content = $"{prefix}.{EncodeBytes(payload)}";
|
||||||
|
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string cursor = $"{content}.{EncodeBytes(signature)}";
|
||||||
|
return ContractValidation.IsCursorValid(cursor)
|
||||||
|
? cursor
|
||||||
|
: throw new InvalidOperationException("The protected cursor exceeds its contract limit.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryUnprotect(string prefix, string? cursor, out byte[] payload)
|
||||||
|
{
|
||||||
|
payload = [];
|
||||||
|
if (_disposed || !ContractValidation.IsCursorValid(cursor))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] segments = cursor!.Split('.');
|
||||||
|
if (segments.Length != 3 || !string.Equals(segments[0], prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] expected = HMACSHA256.HashData(
|
||||||
|
_key,
|
||||||
|
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
|
||||||
|
if (!TryDecodeBytes(segments[2], out byte[] supplied))
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validSignature = supplied.Length == expected.Length
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
|
||||||
|
CryptographicOperations.ZeroMemory(supplied);
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
return validSignature && TryDecodeBytes(segments[1], out payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
CryptographicOperations.ZeroMemory(_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[EphemeralCursorProtector: key redacted]";
|
||||||
|
|
||||||
|
private static string EncodeBytes(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
|
||||||
|
private static bool TryDecodeBytes(string value, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
if (string.IsNullOrEmpty(value)
|
||||||
|
|| value.Any(static character =>
|
||||||
|
character is not (>= 'A' and <= 'Z')
|
||||||
|
and not (>= 'a' and <= 'z')
|
||||||
|
and not (>= '0' and <= '9')
|
||||||
|
and not '-'
|
||||||
|
and not '_'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string padded = value.Replace('-', '+').Replace('_', '/');
|
||||||
|
padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(padded);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed class SessionBrowserCursorCodec : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "rvc1";
|
||||||
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
|
|
||||||
|
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
BrowserCursorPayload payload = new()
|
||||||
|
{
|
||||||
|
GameId = query.Scope.GameId.Value,
|
||||||
|
EnvironmentId = query.Scope.EnvironmentId.Value,
|
||||||
|
ProtocolVersion = query.ProtocolVersion,
|
||||||
|
RegionId = query.RegionId?.Value,
|
||||||
|
ExcludeFull = query.ExcludeFull,
|
||||||
|
AfterListingId = after.ToString(),
|
||||||
|
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _protector.Protect(Prefix, encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDecode(
|
||||||
|
string? cursor,
|
||||||
|
TenantScope scope,
|
||||||
|
uint protocolVersion,
|
||||||
|
RegionId? regionId,
|
||||||
|
bool excludeFull,
|
||||||
|
DateTimeOffset now,
|
||||||
|
out SessionListingId? after)
|
||||||
|
{
|
||||||
|
after = null;
|
||||||
|
if (cursor is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserCursorPayload? payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.Deserialize<BrowserCursorPayload>(
|
||||||
|
encodedPayload,
|
||||||
|
ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is null
|
||||||
|
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||||
|
|| !string.Equals(payload.GameId, scope.GameId.Value, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ProtocolVersion != protocolVersion
|
||||||
|
|| !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ExcludeFull != excludeFull
|
||||||
|
|| !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
after = listingId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _protector.Dispose();
|
||||||
|
|
||||||
|
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class BrowserCursorPayload
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string GameId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string EnvironmentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public uint ProtocolVersion { get; set; }
|
||||||
|
|
||||||
|
public string? RegionId { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool ExcludeFull { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string AfterListingId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionBrowserService(
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
SessionBrowserCursorCodec cursors,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = Validate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
TenantScope scope = new(request.GameId, request.EnvironmentId);
|
||||||
|
if (!cursors.TryDecode(
|
||||||
|
request.Cursor,
|
||||||
|
scope,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.RegionId,
|
||||||
|
request.ExcludeFull,
|
||||||
|
clock.UtcNow,
|
||||||
|
out SessionListingId? after))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
VisibleListingQuery query = new(
|
||||||
|
scope,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.RegionId,
|
||||||
|
request.PageSize + 1,
|
||||||
|
after,
|
||||||
|
request.ExcludeFull);
|
||||||
|
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
|
||||||
|
query,
|
||||||
|
cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||||
|
? RendezvousErrorCode.ServiceUnavailable
|
||||||
|
: RendezvousErrorCode.InternalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SessionListing> items = found.Value
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.Select(ToContract)
|
||||||
|
.ToList();
|
||||||
|
bool hasMore = found.Value.Count > request.PageSize;
|
||||||
|
while (items.Count > 0)
|
||||||
|
{
|
||||||
|
string? nextCursor = hasMore
|
||||||
|
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
||||||
|
: null;
|
||||||
|
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
|
||||||
|
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
||||||
|
<= ContractLimits.BrowserResponseMaxBytes)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.None, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.RemoveAt(items.Count - 1);
|
||||||
|
hasMore = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public BrowserServiceResult<GetSessionResponse> Get(
|
||||||
|
SessionListingId listingId,
|
||||||
|
GameId gameId,
|
||||||
|
EnvironmentId environmentId,
|
||||||
|
uint protocolVersion,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (listingId.Value == Guid.Empty
|
||||||
|
|| string.IsNullOrEmpty(gameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(environmentId.Value)
|
||||||
|
|| protocolVersion == 0)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> found = store.GetListing(listingId, true, cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||||
|
? RendezvousErrorCode.ServiceUnavailable
|
||||||
|
: RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing listing = found.Value;
|
||||||
|
if (listing.Definition.Scope != new TenantScope(gameId, environmentId)
|
||||||
|
|| listing.Definition.ProtocolVersion != protocolVersion)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new GetSessionResponse
|
||||||
|
{
|
||||||
|
Session = ToContract(listing),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
|| (request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value))
|
||||||
|
|| !ContractValidation.IsPageSizeValid(request.PageSize)
|
||||||
|
|| !ContractValidation.IsCursorValid(request.Cursor)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SessionListing ToContract(StoredListing stored) => new()
|
||||||
|
{
|
||||||
|
ListingId = stored.Definition.ListingId,
|
||||||
|
GameId = stored.Definition.Scope.GameId,
|
||||||
|
EnvironmentId = stored.Definition.Scope.EnvironmentId,
|
||||||
|
RegionId = stored.Definition.RegionId,
|
||||||
|
ProtocolVersion = stored.Definition.ProtocolVersion,
|
||||||
|
BuildVersion = stored.Definition.BuildVersion,
|
||||||
|
DisplayName = stored.Definition.DisplayName,
|
||||||
|
Visibility = stored.Definition.Visibility,
|
||||||
|
PublisherTrustMode = stored.Definition.TrustMode,
|
||||||
|
Capacity = new()
|
||||||
|
{
|
||||||
|
CurrentPlayers = stored.Definition.CurrentPlayers,
|
||||||
|
MaximumPlayers = stored.Definition.MaximumPlayers,
|
||||||
|
},
|
||||||
|
Metadata = stored.Definition.Metadata.ToDictionary(
|
||||||
|
static item => item.Key,
|
||||||
|
static item => item.Value,
|
||||||
|
StringComparer.Ordinal),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
|
using System.Net;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Http;
|
namespace FinalFactory.Rendezvous.Server.Http;
|
||||||
@@ -14,34 +20,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 +80,136 @@ 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.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.Status501NotImplemented)
|
||||||
.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,19 +218,119 @@ 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,
|
||||||
@@ -109,4 +344,72 @@ internal static class ContractEndpoints
|
|||||||
},
|
},
|
||||||
ContractJson.Options,
|
ContractJson.Options,
|
||||||
statusCode: NotImplementedStatus);
|
statusCode: NotImplementedStatus);
|
||||||
|
|
||||||
|
private static bool TryAuthenticatePublisher(
|
||||||
|
string? authorizationHeader,
|
||||||
|
PrincipalCredentialService credentials,
|
||||||
|
IWallClock clock,
|
||||||
|
out AuthenticatedPrincipal? principal)
|
||||||
|
{
|
||||||
|
principal = null;
|
||||||
|
const string bearerPrefix = "Bearer ";
|
||||||
|
if (authorizationHeader is null
|
||||||
|
|| !authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string token = authorizationHeader[bearerPrefix.Length..];
|
||||||
|
CredentialValidationResult validation = credentials.Validate(token, clock.UtcNow);
|
||||||
|
if (!validation.IsValid || validation.Principal is not IPublisherPrincipal)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
principal = validation.Principal;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult Error(RendezvousErrorCode code) => Results.Json(
|
||||||
|
new ApiError
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Message = ErrorMessage(code),
|
||||||
|
},
|
||||||
|
ContractJson.Options,
|
||||||
|
statusCode: ErrorStatus(code));
|
||||||
|
|
||||||
|
private static IResult AuthenticationRequired(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.Headers.WWWAuthenticate = "Bearer";
|
||||||
|
return Error(RendezvousErrorCode.AuthenticationRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ErrorStatus(RendezvousErrorCode code) => code switch
|
||||||
|
{
|
||||||
|
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
|
||||||
|
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
|
||||||
|
RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound,
|
||||||
|
RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
|
||||||
|
RendezvousErrorCode.Expired => StatusCodes.Status410Gone,
|
||||||
|
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
|
||||||
|
StatusCodes.Status429TooManyRequests,
|
||||||
|
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
|
||||||
|
RendezvousErrorCode.InternalError => StatusCodes.Status500InternalServerError,
|
||||||
|
_ => StatusCodes.Status400BadRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ErrorMessage(RendezvousErrorCode code) => code switch
|
||||||
|
{
|
||||||
|
RendezvousErrorCode.AuthenticationRequired => "A valid publisher bearer credential is required.",
|
||||||
|
RendezvousErrorCode.Forbidden => "The publisher is not authorized for this operation.",
|
||||||
|
RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.",
|
||||||
|
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
|
||||||
|
RendezvousErrorCode.Expired => "The session lease has expired.",
|
||||||
|
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
|
||||||
|
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
|
||||||
|
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
|
||||||
|
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
|
||||||
|
_ => "The session request is invalid.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Http;
|
||||||
|
|
||||||
|
internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
||||||
|
{
|
||||||
|
public async ValueTask<bool> TryHandleAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (httpContext.Response.HasStarted)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool invalidRequest = exception is BadHttpRequestException or JsonException;
|
||||||
|
httpContext.Response.StatusCode = invalidRequest
|
||||||
|
? StatusCodes.Status400BadRequest
|
||||||
|
: StatusCodes.Status500InternalServerError;
|
||||||
|
await httpContext.Response.WriteAsJsonAsync(
|
||||||
|
new ApiError
|
||||||
|
{
|
||||||
|
Code = invalidRequest
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.InternalError,
|
||||||
|
Message = invalidRequest
|
||||||
|
? "The request body, route, or query value is invalid."
|
||||||
|
: "The service could not complete the request.",
|
||||||
|
},
|
||||||
|
ContractJson.Options,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptCursorCodec : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "rvj1";
|
||||||
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
|
|
||||||
|
public string Encode(
|
||||||
|
SessionListingId listingId,
|
||||||
|
JoinAttemptId after,
|
||||||
|
DateTimeOffset now)
|
||||||
|
{
|
||||||
|
JoinAttemptCursorPayload payload = new()
|
||||||
|
{
|
||||||
|
ListingId = listingId.ToString(),
|
||||||
|
AfterAttemptId = after.ToString(),
|
||||||
|
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _protector.Protect(Prefix, encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDecode(
|
||||||
|
string? cursor,
|
||||||
|
SessionListingId listingId,
|
||||||
|
DateTimeOffset now,
|
||||||
|
out JoinAttemptId? after)
|
||||||
|
{
|
||||||
|
after = null;
|
||||||
|
if (cursor is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinAttemptCursorPayload? payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.Deserialize<JoinAttemptCursorPayload>(
|
||||||
|
encodedPayload,
|
||||||
|
ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is null
|
||||||
|
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||||
|
|| !string.Equals(payload.ListingId, listingId.ToString(), StringComparison.Ordinal)
|
||||||
|
|| !JoinAttemptId.TryParse(payload.AfterAttemptId, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
after = attemptId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _protector.Dispose();
|
||||||
|
|
||||||
|
public override string ToString() => "[JoinAttemptCursorCodec: key and cursors redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptCursorPayload
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string ListingId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string AfterAttemptId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed record JoinAttemptServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ConnectionTicketGrant(string Ticket, DateTimeOffset ExpiresAt)
|
||||||
|
{
|
||||||
|
public override string ToString() => "[ConnectionTicketGrant: ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptService(
|
||||||
|
GamePolicyRegistry policies,
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
JoinAttemptCursorCodec cursors,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public string CreateAnonymousClientSubject(IPAddress remoteAddress)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(remoteAddress);
|
||||||
|
IPAddress normalized = remoteAddress.IsIPv4MappedToIPv6
|
||||||
|
? remoteAddress.MapToIPv4()
|
||||||
|
: remoteAddress;
|
||||||
|
return capabilities.DeriveOpaqueIdentifier("join-http-client", normalized.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<CreateJoinAttemptResponse> Create(
|
||||||
|
string clientSubject,
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
if (string.IsNullOrWhiteSpace(clientSubject))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A bounded client subject is required.", nameof(clientSubject));
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode validation = ValidateCreate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policies.TryGet(request.GameId, request.EnvironmentId, out GamePolicy? policy)
|
||||||
|
|| policy is null)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.AllowsProtocol(request.ProtocolVersion))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.IncompatibleProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
string requestFingerprint = ComputeRequestFingerprint(request);
|
||||||
|
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||||
|
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|
||||||
|
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(connectionTicket, out SecretFingerprint ticketFingerprint))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
|
||||||
|
"join-attempt-id",
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
|
||||||
|
"join-mediation-handle",
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
StoreResult<StoredJoinAttempt> created = store.CreateJoinAttempt(new()
|
||||||
|
{
|
||||||
|
IdempotencyOwner = clientSubject,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
RequestFingerprint = requestFingerprint,
|
||||||
|
ClientSubject = clientSubject,
|
||||||
|
AttemptId = attemptId,
|
||||||
|
MediationHandle = mediationHandle,
|
||||||
|
Scope = new(request.GameId, request.EnvironmentId),
|
||||||
|
ListingId = request.ListingId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
HostCapabilityFingerprint = hostFingerprint,
|
||||||
|
ClientCapabilityFingerprint = clientFingerprint,
|
||||||
|
ConnectionTicketFingerprint = ticketFingerprint,
|
||||||
|
CapabilityDerivationSalt = derivationSalt,
|
||||||
|
ScopeAttemptLimit = policy.MaxActiveJoinAttempts,
|
||||||
|
}, cancellationToken);
|
||||||
|
if (!created.Succeeded || created.Value is null)
|
||||||
|
{
|
||||||
|
return new(created.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredJoinAttempt persisted = created.Value;
|
||||||
|
clientCapability = Derive(
|
||||||
|
"join-client-punch",
|
||||||
|
persisted.ClientSubject,
|
||||||
|
persisted.IdempotencyKey,
|
||||||
|
persisted.RequestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
if (!capabilities.TryFingerprint(clientCapability, out SecretFingerprint persistedFingerprint)
|
||||||
|
|| persistedFingerprint != persisted.ClientCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its client capability.");
|
||||||
|
}
|
||||||
|
return new(RendezvousErrorCode.None, new CreateJoinAttemptResponse
|
||||||
|
{
|
||||||
|
AttemptId = persisted.AttemptId,
|
||||||
|
MediationHandle = persisted.MediationHandle,
|
||||||
|
ClientPunchCapability = clientCapability,
|
||||||
|
ExpiresAt = persisted.ExpiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> BrowseForHost(
|
||||||
|
SessionListingId listingId,
|
||||||
|
int contractVersion,
|
||||||
|
string? leaseToken,
|
||||||
|
int pageSize,
|
||||||
|
string? cursor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||||
|
|| !ContractValidation.IsPageSizeValid(pageSize)
|
||||||
|
|| !ContractValidation.IsCursorValid(cursor)
|
||||||
|
|| !capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cursors.TryDecode(cursor, listingId, clock.UtcNow, out JoinAttemptId? after))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<IReadOnlyList<StoredJoinAttempt>> found = store.BrowseHostJoinAttempts(new(
|
||||||
|
listingId,
|
||||||
|
leaseFingerprint,
|
||||||
|
pageSize + 1,
|
||||||
|
after), cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasMore = found.Value.Count > pageSize;
|
||||||
|
StoredJoinAttempt[] page = found.Value.Take(pageSize).ToArray();
|
||||||
|
BrowseHostJoinAttemptsResponse response = new()
|
||||||
|
{
|
||||||
|
Items = page.Select(CreateHostAttempt).ToList(),
|
||||||
|
NextCursor = hasMore && page.Length > 0
|
||||||
|
? cursors.Encode(listingId, page[^1].AttemptId, clock.UtcNow)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||||
|
return ContractValidation.IsBrowserResponseSizeValid(encodedBytes)
|
||||||
|
? new(RendezvousErrorCode.None, response)
|
||||||
|
: new(RendezvousErrorCode.CapacityExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<bool> Cancel(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string? clientPunchCapability,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsCapabilityValid(clientPunchCapability)
|
||||||
|
|| !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<bool> cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken);
|
||||||
|
return cancelled.Succeeded
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(cancelled.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<ConnectionTicketGrant> IssueConnectionTicket(
|
||||||
|
StoredJoinAttempt attempt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(attempt);
|
||||||
|
if (!attempt.IntroductionConsumed)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
string ticket = Derive(
|
||||||
|
"connection-ticket",
|
||||||
|
attempt.ClientSubject,
|
||||||
|
attempt.IdempotencyKey,
|
||||||
|
attempt.RequestFingerprint,
|
||||||
|
attempt.CapabilityDerivationSalt);
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(ticket)
|
||||||
|
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|
||||||
|
|| fingerprint != attempt.ConnectionTicketFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its connection ticket.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new(ticket, attempt.ConnectionTicketExpiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private HostJoinAttempt CreateHostAttempt(StoredJoinAttempt attempt)
|
||||||
|
{
|
||||||
|
string capability = Derive(
|
||||||
|
"join-host-punch",
|
||||||
|
attempt.ClientSubject,
|
||||||
|
attempt.IdempotencyKey,
|
||||||
|
attempt.RequestFingerprint,
|
||||||
|
attempt.CapabilityDerivationSalt);
|
||||||
|
if (!ContractValidation.IsCapabilityValid(capability)
|
||||||
|
|| !capabilities.TryFingerprint(capability, out SecretFingerprint fingerprint)
|
||||||
|
|| fingerprint != attempt.HostCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its host capability.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
AttemptId = attempt.AttemptId,
|
||||||
|
MediationHandle = attempt.MediationHandle,
|
||||||
|
HostPunchCapability = capability,
|
||||||
|
ExpiresAt = attempt.ExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||||
|
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| request.ListingId.Value == Guid.Empty
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeRequestFingerprint(CreateJoinAttemptRequest request)
|
||||||
|
{
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(request, ContractJson.Options);
|
||||||
|
byte[] digest = SHA256.HashData(encoded);
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Derive(
|
||||||
|
string purpose,
|
||||||
|
string clientSubject,
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt) => Derive(
|
||||||
|
purpose,
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
|
||||||
|
private string Derive(
|
||||||
|
string purpose,
|
||||||
|
string clientSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt) => capabilities.DeriveCapability(
|
||||||
|
purpose,
|
||||||
|
clientSubject,
|
||||||
|
idempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
|
||||||
|
private static bool CredentialLengthsAreValid(
|
||||||
|
string hostCapability,
|
||||||
|
string clientCapability,
|
||||||
|
string ticket) =>
|
||||||
|
ContractValidation.IsCapabilityValid(hostCapability)
|
||||||
|
&& ContractValidation.IsCapabilityValid(clientCapability)
|
||||||
|
&& ContractValidation.IsConnectionTicketValid(ticket)
|
||||||
|
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
|
||||||
|
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
|
||||||
|
|
||||||
|
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
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.State;
|
||||||
using FinalFactory.Rendezvous.Server.Transport;
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
@@ -13,6 +16,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;
|
||||||
@@ -32,16 +36,65 @@ 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.",
|
||||||
|
};
|
||||||
|
|
||||||
|
HashSet<string> securedOperations = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"RegisterSession",
|
||||||
|
"RenewSessionLease",
|
||||||
|
"UpdateSession",
|
||||||
|
"DeleteSession",
|
||||||
|
};
|
||||||
|
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
|
||||||
|
foreach (OpenApiPathItem path in document.Paths.Values)
|
||||||
|
{
|
||||||
|
if (path.Operations is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
||||||
|
operation => securedOperations.Contains(operation.OperationId ?? string.Empty)))
|
||||||
|
{
|
||||||
|
operation.Security ??= [];
|
||||||
|
operation.Security.Add(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
[reference] = [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
});
|
||||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
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();
|
SystemRendezvousClock rendezvousClock = new();
|
||||||
|
EphemeralStoreOptions stateOptions = new();
|
||||||
InMemoryEphemeralRendezvousStore stateStore = new(
|
InMemoryEphemeralRendezvousStore stateStore = new(
|
||||||
new EphemeralStoreOptions(),
|
stateOptions,
|
||||||
rendezvousClock,
|
rendezvousClock,
|
||||||
rendezvousClock);
|
rendezvousClock);
|
||||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
||||||
|
|
||||||
if (isOpenApiGeneration)
|
if (isOpenApiGeneration)
|
||||||
{
|
{
|
||||||
@@ -63,6 +116,15 @@ 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(new ProvisioningReadiness(true));
|
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +146,7 @@ if (!isOpenApiGeneration)
|
|||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
||||||
|
|
||||||
|
app.UseExceptionHandler();
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
app.MapRendezvousContractEndpoints();
|
app.MapRendezvousContractEndpoints();
|
||||||
app.MapGet(
|
app.MapGet(
|
||||||
|
|||||||
@@ -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,440 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
|
||||||
|
internal sealed record SessionLeaseTiming(
|
||||||
|
int LeaseRenewAfterSeconds,
|
||||||
|
int HostPresenceRefreshAfterSeconds)
|
||||||
|
{
|
||||||
|
public static SessionLeaseTiming From(EphemeralStoreOptions options) => new(
|
||||||
|
Math.Max(1, (int)(options.LeaseLifetime.TotalSeconds / 2)),
|
||||||
|
Math.Max(1, (int)(options.PresenceLifetime.TotalSeconds / 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SessionServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionLeaseService(
|
||||||
|
PublisherAuthorizationService authorization,
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
SessionLeaseTiming timing,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public SessionServiceResult<RegisterSessionResponse> Register(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
RegisterSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateRegistration(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
PublisherAuthorizationResult authorized = authorization.Authorize(
|
||||||
|
principal,
|
||||||
|
request.GameId,
|
||||||
|
request.EnvironmentId,
|
||||||
|
request.RegionId,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.Visibility,
|
||||||
|
request.Metadata,
|
||||||
|
clock.UtcNow);
|
||||||
|
if (!authorized.IsAllowed || authorized.Context is null)
|
||||||
|
{
|
||||||
|
return new(MapAuthorization(authorized.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthorizedPublisherContext context = authorized.Context;
|
||||||
|
string requestFingerprint = ComputeRegistrationFingerprint(request);
|
||||||
|
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||||
|
string leaseToken = capabilities.DeriveCapability(
|
||||||
|
"lease-token",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
string presenceCapability = capabilities.DeriveCapability(
|
||||||
|
"host-presence",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(presenceCapability, out SecretFingerprint presenceFingerprint))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Derived session capabilities could not be fingerprinted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionListingId listingId = new(capabilities.DeriveGuid(
|
||||||
|
"listing-id",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
LeaseId leaseId = new(capabilities.DeriveGuid(
|
||||||
|
"lease-id",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
MediationHandle presenceHandle = new(capabilities.DeriveGuid(
|
||||||
|
"presence-handle",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
int ownerLimit = context.TrustMode == PublisherTrustMode.AnonymousUnlisted
|
||||||
|
? context.Policy.MaxAnonymousListingsPerAddress
|
||||||
|
: context.Policy.MaxListingsPerPrincipal;
|
||||||
|
if (ownerLimit <= 0)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.CapacityExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> created = store.CreateListing(new(
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
new ListingDefinition
|
||||||
|
{
|
||||||
|
ListingId = listingId,
|
||||||
|
LeaseId = leaseId,
|
||||||
|
Scope = new(context.GameId, context.EnvironmentId),
|
||||||
|
OwnerSubject = context.Subject,
|
||||||
|
RegionId = context.RegionId,
|
||||||
|
ProtocolVersion = context.ProtocolVersion,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Visibility = context.Visibility,
|
||||||
|
TrustMode = context.TrustMode,
|
||||||
|
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||||
|
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||||
|
Metadata = request.Metadata,
|
||||||
|
LeaseFingerprint = leaseFingerprint,
|
||||||
|
HostPresenceHandle = presenceHandle,
|
||||||
|
HostPresenceFingerprint = presenceFingerprint,
|
||||||
|
CapabilityDerivationSalt = derivationSalt,
|
||||||
|
},
|
||||||
|
ownerLimit), cancellationToken);
|
||||||
|
if (!created.Succeeded || created.Value is null)
|
||||||
|
{
|
||||||
|
return new(created.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
ListingDefinition persisted = created.Value.Definition;
|
||||||
|
leaseToken = capabilities.DeriveCapability(
|
||||||
|
"lease-token",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
presenceCapability = capabilities.DeriveCapability(
|
||||||
|
"host-presence",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new RegisterSessionResponse
|
||||||
|
{
|
||||||
|
ListingId = persisted.ListingId,
|
||||||
|
LeaseId = persisted.LeaseId,
|
||||||
|
LeaseToken = leaseToken,
|
||||||
|
HostPresenceHandle = persisted.HostPresenceHandle,
|
||||||
|
HostPresenceCapability = presenceCapability,
|
||||||
|
ExpiresAt = created.Value.LeaseExpiresAt,
|
||||||
|
LeaseRenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||||
|
HostPresenceRefreshAfterSeconds = timing.HostPresenceRefreshAfterSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionServiceResult<RenewLeaseResponse> Renew(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
RenewLeaseRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||||
|
principal,
|
||||||
|
listingId,
|
||||||
|
request.LeaseToken,
|
||||||
|
cancellationToken,
|
||||||
|
out StoredListing? listing);
|
||||||
|
if (lookup != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing ownedListing = listing!;
|
||||||
|
PublisherAuthorizationResult authorized = AuthorizeExisting(
|
||||||
|
principal,
|
||||||
|
ownedListing,
|
||||||
|
ownedListing.Definition.Metadata);
|
||||||
|
if (!authorized.IsAllowed)
|
||||||
|
{
|
||||||
|
return new(MapAuthorization(authorized.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||||
|
StoreResult<StoredListing> renewed = store.RenewLease(new(
|
||||||
|
listingId,
|
||||||
|
ownedListing.Definition.LeaseId,
|
||||||
|
fingerprint,
|
||||||
|
ownedListing.Definition.OwnerSubject,
|
||||||
|
ownedListing.Version), cancellationToken);
|
||||||
|
return renewed.Succeeded && renewed.Value is not null
|
||||||
|
? new(RendezvousErrorCode.None, new RenewLeaseResponse
|
||||||
|
{
|
||||||
|
ExpiresAt = renewed.Value.LeaseExpiresAt,
|
||||||
|
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||||
|
})
|
||||||
|
: new(renewed.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionServiceResult<bool> Update(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
UpdateSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateUpdate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||||
|
principal,
|
||||||
|
listingId,
|
||||||
|
request.LeaseToken,
|
||||||
|
cancellationToken,
|
||||||
|
out StoredListing? listing);
|
||||||
|
if (lookup != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing ownedListing = listing!;
|
||||||
|
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
|
||||||
|
if (!authorized.IsAllowed)
|
||||||
|
{
|
||||||
|
return new(MapAuthorization(authorized.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||||
|
StoreResult<StoredListing> updated = store.UpdateListing(new(
|
||||||
|
listingId,
|
||||||
|
ownedListing.Definition.LeaseId,
|
||||||
|
fingerprint,
|
||||||
|
ownedListing.Definition.OwnerSubject,
|
||||||
|
request.BuildVersion,
|
||||||
|
request.DisplayName,
|
||||||
|
request.Capacity.CurrentPlayers,
|
||||||
|
request.Capacity.MaximumPlayers,
|
||||||
|
request.Metadata), cancellationToken);
|
||||||
|
return updated.Succeeded
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(updated.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionServiceResult<bool> Delete(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
DeleteSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (principal is not IPublisherPrincipal publisher
|
||||||
|
|| !capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return found.Code == StoreResultCode.ServiceUnavailable
|
||||||
|
? new(RendezvousErrorCode.ServiceUnavailable)
|
||||||
|
: new(RendezvousErrorCode.None, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<bool> deleted = store.DeleteListing(new(
|
||||||
|
listingId,
|
||||||
|
found.Value.Definition.LeaseId,
|
||||||
|
fingerprint,
|
||||||
|
publisher.Subject), cancellationToken);
|
||||||
|
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(deleted.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
private RendezvousErrorCode GetAuthorizedListing(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
string leaseToken,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
out StoredListing? listing)
|
||||||
|
{
|
||||||
|
listing = null;
|
||||||
|
if (principal is not IPublisherPrincipal publisher)
|
||||||
|
{
|
||||||
|
return RendezvousErrorCode.Forbidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return RendezvousErrorCode.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return found.Code.ToContractError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|
||||||
|
|| found.Value.Definition.LeaseFingerprint != fingerprint)
|
||||||
|
{
|
||||||
|
return RendezvousErrorCode.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
listing = found.Value;
|
||||||
|
return RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublisherAuthorizationResult AuthorizeExisting(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
StoredListing listing,
|
||||||
|
IReadOnlyDictionary<string, string> metadata) => authorization.Authorize(
|
||||||
|
principal,
|
||||||
|
listing.Definition.Scope.GameId,
|
||||||
|
listing.Definition.Scope.EnvironmentId,
|
||||||
|
listing.Definition.RegionId,
|
||||||
|
listing.Definition.ProtocolVersion,
|
||||||
|
listing.Definition.Visibility,
|
||||||
|
metadata,
|
||||||
|
clock.UtcNow);
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||||
|
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.RegionId.Value)
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
|| !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|
||||||
|
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||||
|
|| !Enum.IsDefined(request.Visibility)
|
||||||
|
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||||
|
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateUpdate(UpdateSessionRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode lease = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||||
|
if (lease != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|
||||||
|
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||||
|
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||||
|
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateLeaseRequest(int contractVersion, string leaseToken)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||||
|
? RendezvousErrorCode.None
|
||||||
|
: RendezvousErrorCode.InvalidRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode MapAuthorization(PublisherAuthorizationError error) => error switch
|
||||||
|
{
|
||||||
|
PublisherAuthorizationError.PrincipalExpired => RendezvousErrorCode.AuthenticationRequired,
|
||||||
|
PublisherAuthorizationError.ProtocolNotAllowed => RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
PublisherAuthorizationError.RegionNotAllowed
|
||||||
|
or PublisherAuthorizationError.VisibilityNotAllowed
|
||||||
|
or PublisherAuthorizationError.AnonymousMustBeUnlisted
|
||||||
|
or PublisherAuthorizationError.MetadataNotAllowed => RendezvousErrorCode.InvalidRequest,
|
||||||
|
_ => RendezvousErrorCode.Forbidden,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
|
||||||
|
{
|
||||||
|
RegisterSessionRequest canonical = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Visibility = request.Visibility,
|
||||||
|
Capacity = new SessionCapacity
|
||||||
|
{
|
||||||
|
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||||
|
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||||
|
},
|
||||||
|
Metadata = request.Metadata
|
||||||
|
.OrderBy(static item => item.Key, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
|
||||||
|
byte[] digest = SHA256.HashData(encoded);
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(digest).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ internal sealed record EphemeralStoreOptions
|
|||||||
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
|
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
|
||||||
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||||
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
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 ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
|
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
|
||||||
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
@@ -50,9 +51,23 @@ internal sealed record EphemeralStoreOptions
|
|||||||
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
|
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
|
||||||
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
|
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
|
||||||
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
|
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
|
||||||
|
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
|
||||||
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
||||||
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
||||||
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
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)
|
private static void RequirePositive(int value, string name)
|
||||||
@@ -74,8 +89,10 @@ internal sealed record EphemeralStoreOptions
|
|||||||
|
|
||||||
internal readonly record struct TenantScope(GameId GameId, EnvironmentId EnvironmentId);
|
internal readonly record struct TenantScope(GameId GameId, EnvironmentId EnvironmentId);
|
||||||
|
|
||||||
internal readonly record struct SecretFingerprint
|
internal readonly struct SecretFingerprint : IEquatable<SecretFingerprint>
|
||||||
{
|
{
|
||||||
|
private readonly string? _value;
|
||||||
|
|
||||||
public SecretFingerprint(string value)
|
public SecretFingerprint(string value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
|
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
|
||||||
@@ -83,12 +100,33 @@ internal readonly record struct SecretFingerprint
|
|||||||
throw new ArgumentException("Secret fingerprints must contain 1-128 characters.", nameof(value));
|
throw new ArgumentException("Secret fingerprints must contain 1-128 characters.", nameof(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
Value = value;
|
_value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Value { get; }
|
public bool IsValid => !string.IsNullOrWhiteSpace(_value) && _value.Length <= 128;
|
||||||
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 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
|
internal readonly record struct ObservedEndpoint
|
||||||
@@ -138,6 +176,7 @@ internal sealed record ListingDefinition
|
|||||||
public required SecretFingerprint LeaseFingerprint { get; init; }
|
public required SecretFingerprint LeaseFingerprint { get; init; }
|
||||||
public required MediationHandle HostPresenceHandle { get; init; }
|
public required MediationHandle HostPresenceHandle { get; init; }
|
||||||
public required SecretFingerprint HostPresenceFingerprint { get; init; }
|
public required SecretFingerprint HostPresenceFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record StoredListing
|
internal sealed record StoredListing
|
||||||
@@ -163,12 +202,25 @@ internal sealed record RenewLeaseCommand(
|
|||||||
SessionListingId ListingId,
|
SessionListingId ListingId,
|
||||||
LeaseId LeaseId,
|
LeaseId LeaseId,
|
||||||
SecretFingerprint LeaseFingerprint,
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
string OwnerSubject,
|
||||||
long ExpectedVersion);
|
long ExpectedVersion);
|
||||||
|
|
||||||
|
internal sealed record UpdateListingCommand(
|
||||||
|
SessionListingId ListingId,
|
||||||
|
LeaseId LeaseId,
|
||||||
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
string OwnerSubject,
|
||||||
|
string BuildVersion,
|
||||||
|
string DisplayName,
|
||||||
|
int CurrentPlayers,
|
||||||
|
int MaximumPlayers,
|
||||||
|
IReadOnlyDictionary<string, string> Metadata);
|
||||||
|
|
||||||
internal sealed record DeleteListingCommand(
|
internal sealed record DeleteListingCommand(
|
||||||
SessionListingId ListingId,
|
SessionListingId ListingId,
|
||||||
LeaseId LeaseId,
|
LeaseId LeaseId,
|
||||||
SecretFingerprint LeaseFingerprint);
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
string OwnerSubject);
|
||||||
|
|
||||||
internal sealed record BindHostPresenceCommand(
|
internal sealed record BindHostPresenceCommand(
|
||||||
MediationHandle Handle,
|
MediationHandle Handle,
|
||||||
@@ -180,7 +232,9 @@ internal sealed record VisibleListingQuery(
|
|||||||
TenantScope Scope,
|
TenantScope Scope,
|
||||||
uint ProtocolVersion,
|
uint ProtocolVersion,
|
||||||
RegionId? RegionId,
|
RegionId? RegionId,
|
||||||
int MaximumResults = ContractLimits.BrowserPageMaxItems);
|
int MaximumResults = ContractLimits.BrowserPageMaxItems,
|
||||||
|
SessionListingId? AfterListingId = null,
|
||||||
|
bool ExcludeFull = false);
|
||||||
|
|
||||||
internal enum AttemptPeerRole
|
internal enum AttemptPeerRole
|
||||||
{
|
{
|
||||||
@@ -201,7 +255,11 @@ internal sealed record CreateJoinAttemptCommand
|
|||||||
public required uint ProtocolVersion { get; init; }
|
public required uint ProtocolVersion { get; init; }
|
||||||
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||||
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
||||||
|
|
||||||
|
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record AttemptEndpointBinding(
|
internal sealed record AttemptEndpointBinding(
|
||||||
@@ -216,12 +274,28 @@ internal sealed record StoredJoinAttempt
|
|||||||
public required SessionListingId ListingId { get; init; }
|
public required SessionListingId ListingId { get; init; }
|
||||||
public required string ClientSubject { get; init; }
|
public required string ClientSubject { get; init; }
|
||||||
public required uint ProtocolVersion { get; init; }
|
public required uint ProtocolVersion { get; init; }
|
||||||
|
public required string IdempotencyKey { get; init; }
|
||||||
|
public required string RequestFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||||
public required DateTimeOffset ExpiresAt { get; init; }
|
public required DateTimeOffset ExpiresAt { get; init; }
|
||||||
|
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
||||||
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
||||||
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||||
public required bool IntroductionConsumed { get; init; }
|
public required bool IntroductionConsumed { get; init; }
|
||||||
|
public required bool ConnectionTicketConsumed { get; init; }
|
||||||
|
|
||||||
|
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record HostJoinAttemptQuery(
|
||||||
|
SessionListingId ListingId,
|
||||||
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
int MaximumResults,
|
||||||
|
JoinAttemptId? AfterAttemptId = null);
|
||||||
|
|
||||||
internal sealed record BindAttemptEndpointCommand(
|
internal sealed record BindAttemptEndpointCommand(
|
||||||
MediationHandle Handle,
|
MediationHandle Handle,
|
||||||
AttemptPeerRole Role,
|
AttemptPeerRole Role,
|
||||||
@@ -230,9 +304,20 @@ internal sealed record BindAttemptEndpointCommand(
|
|||||||
ObservedEndpoint? LocalEndpoint);
|
ObservedEndpoint? LocalEndpoint);
|
||||||
|
|
||||||
internal sealed record IntroductionEndpoints(
|
internal sealed record IntroductionEndpoints(
|
||||||
JoinAttemptId AttemptId,
|
StoredJoinAttempt Attempt,
|
||||||
AttemptEndpointBinding Host,
|
AttemptEndpointBinding Host,
|
||||||
AttemptEndpointBinding Client);
|
AttemptEndpointBinding Client)
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId => Attempt.AttemptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record CancelJoinAttemptCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ClientCapabilityFingerprint);
|
||||||
|
|
||||||
|
internal sealed record ConsumeConnectionTicketCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ConnectionTicketFingerprint);
|
||||||
|
|
||||||
internal sealed record ReplayConsumption(
|
internal sealed record ReplayConsumption(
|
||||||
string Namespace,
|
string Namespace,
|
||||||
@@ -265,13 +350,17 @@ internal interface IEphemeralRendezvousStore
|
|||||||
|
|
||||||
StoreResult<StoredListing> CreateListing(CreateListingCommand command, CancellationToken cancellationToken = default);
|
StoreResult<StoredListing> CreateListing(CreateListingCommand command, CancellationToken cancellationToken = default);
|
||||||
StoreResult<StoredListing> RenewLease(RenewLeaseCommand 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<bool> DeleteListing(DeleteListingCommand command, CancellationToken cancellationToken = default);
|
||||||
StoreResult<StoredListing> GetListing(SessionListingId listingId, bool requireFreshPresence, CancellationToken cancellationToken = default);
|
StoreResult<StoredListing> GetListing(SessionListingId listingId, bool requireFreshPresence, CancellationToken cancellationToken = default);
|
||||||
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
|
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
|
||||||
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
|
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
|
||||||
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||||
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
||||||
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, 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> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
|
||||||
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
|
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
|
||||||
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
|
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return admission;
|
return admission;
|
||||||
}
|
}
|
||||||
|
|
||||||
string idempotencyKey = $"listing:{command.Listing.OwnerSubject}:{command.IdempotencyKey}";
|
string idempotencyKey = $"listing:{command.Listing.Scope.GameId}:{command.Listing.Scope.EnvironmentId}:{command.Listing.OwnerSubject}:{command.IdempotencyKey}";
|
||||||
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
|
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
|
||||||
{
|
{
|
||||||
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
|
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
|
||||||
@@ -151,7 +151,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entry.Definition.LeaseId != command.LeaseId
|
if (entry.Definition.LeaseId != command.LeaseId
|
||||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint)
|
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|
||||||
|
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
}
|
}
|
||||||
@@ -167,6 +168,52 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
return new(StoreResultCode.Success, Snapshot(entry));
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public StoreResult<StoredListing> UpdateListing(
|
||||||
|
UpdateListingCommand command,
|
||||||
|
CancellationToken cancellationToken = default) => Atomic<StoredListing>(_ =>
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
ValidateSubject(command.OwnerSubject, nameof(command.OwnerSubject));
|
||||||
|
if (!ContractValidation.IsBuildVersionValid(command.BuildVersion)
|
||||||
|
|| !ContractValidation.IsDisplayNameValid(command.DisplayName)
|
||||||
|
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
||||||
|
|| command.CurrentPlayers < 0
|
||||||
|
|| command.CurrentPlayers > command.MaximumPlayers
|
||||||
|
|| !ContractValidation.IsMetadataValid(command.Metadata))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Listing update invariants are invalid.", nameof(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_available)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_drainDeadline.HasValue)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.Draining);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry)
|
||||||
|
|| entry.Definition.LeaseId != command.LeaseId
|
||||||
|
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|
||||||
|
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Definition = StoredListing.Freeze(entry.Definition with
|
||||||
|
{
|
||||||
|
BuildVersion = command.BuildVersion,
|
||||||
|
DisplayName = command.DisplayName,
|
||||||
|
CurrentPlayers = command.CurrentPlayers,
|
||||||
|
MaximumPlayers = command.MaximumPlayers,
|
||||||
|
Metadata = command.Metadata,
|
||||||
|
});
|
||||||
|
entry.Version++;
|
||||||
|
return new(StoreResultCode.Success, Snapshot(entry));
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
public StoreResult<bool> DeleteListing(
|
public StoreResult<bool> DeleteListing(
|
||||||
DeleteListingCommand command,
|
DeleteListingCommand command,
|
||||||
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
|
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
|
||||||
@@ -174,7 +221,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
ArgumentNullException.ThrowIfNull(command);
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry)
|
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry)
|
||||||
|| entry.Definition.LeaseId != command.LeaseId
|
|| entry.Definition.LeaseId != command.LeaseId
|
||||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint)
|
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|
||||||
|
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
}
|
}
|
||||||
@@ -253,7 +301,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|| query.ProtocolVersion == 0
|
|| query.ProtocolVersion == 0
|
||||||
|| (query.RegionId.HasValue && string.IsNullOrEmpty(query.RegionId.Value.Value))
|
|| (query.RegionId.HasValue && string.IsNullOrEmpty(query.RegionId.Value.Value))
|
||||||
|| query.MaximumResults <= 0
|
|| query.MaximumResults <= 0
|
||||||
|| query.MaximumResults > ContractLimits.BrowserPageMaxItems)
|
|| query.MaximumResults > ContractLimits.BrowserPageMaxItems + 1)
|
||||||
{
|
{
|
||||||
throw new ArgumentOutOfRangeException(nameof(query));
|
throw new ArgumentOutOfRangeException(nameof(query));
|
||||||
}
|
}
|
||||||
@@ -263,6 +311,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
&& entry.Definition.ProtocolVersion == query.ProtocolVersion
|
&& entry.Definition.ProtocolVersion == query.ProtocolVersion
|
||||||
&& entry.Definition.Visibility == ListingVisibility.Public
|
&& entry.Definition.Visibility == ListingVisibility.Public
|
||||||
&& (!query.RegionId.HasValue || entry.Definition.RegionId == query.RegionId.Value)
|
&& (!query.RegionId.HasValue || entry.Definition.RegionId == query.RegionId.Value)
|
||||||
|
&& (!query.AfterListingId.HasValue
|
||||||
|
|| entry.Definition.ListingId.Value.CompareTo(query.AfterListingId.Value.Value) > 0)
|
||||||
|
&& (!query.ExcludeFull
|
||||||
|
|| entry.Definition.CurrentPlayers < entry.Definition.MaximumPlayers)
|
||||||
&& _presence.ContainsKey(entry.Definition.HostPresenceHandle))
|
&& _presence.ContainsKey(entry.Definition.HostPresenceHandle))
|
||||||
.OrderBy(static entry => entry.Definition.ListingId.Value)
|
.OrderBy(static entry => entry.Definition.ListingId.Value)
|
||||||
.Take(query.MaximumResults)
|
.Take(query.MaximumResults)
|
||||||
@@ -287,7 +339,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return admission;
|
return admission;
|
||||||
}
|
}
|
||||||
|
|
||||||
string idempotencyKey = $"attempt:{command.IdempotencyOwner}:{command.IdempotencyKey}";
|
string idempotencyKey = $"attempt:{command.Scope.GameId}:{command.Scope.EnvironmentId}:{command.IdempotencyOwner}:{command.IdempotencyKey}";
|
||||||
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
|
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
|
||||||
{
|
{
|
||||||
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
|
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
|
||||||
@@ -338,6 +390,66 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.Success, Snapshot(attempt));
|
return new(StoreResultCode.Success, Snapshot(attempt));
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(
|
||||||
|
HostJoinAttemptQuery query,
|
||||||
|
CancellationToken cancellationToken = default) => Atomic<IReadOnlyList<StoredJoinAttempt>>(_ =>
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
if (query.ListingId.Value == Guid.Empty
|
||||||
|
|| !query.LeaseFingerprint.IsValid
|
||||||
|
|| query.MaximumResults is < 1 or > ContractLimits.BrowserPageMaxItems + 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Host attempt query invariants are invalid.", nameof(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_available)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_listings.TryGetValue(query.ListingId, out ListingEntry? listing)
|
||||||
|
|| listing.Definition.LeaseFingerprint != query.LeaseFingerprint)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values
|
||||||
|
.Where(entry => entry.Command.ListingId == query.ListingId
|
||||||
|
&& !entry.IntroductionConsumed
|
||||||
|
&& (!query.AfterAttemptId.HasValue
|
||||||
|
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
|
||||||
|
.OrderBy(static entry => entry.Command.AttemptId.Value)
|
||||||
|
.Take(query.MaximumResults)
|
||||||
|
.Select(Snapshot)
|
||||||
|
.ToArray();
|
||||||
|
return new(StoreResultCode.Success, attempts);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public StoreResult<bool> CancelJoinAttempt(
|
||||||
|
CancelJoinAttemptCommand command,
|
||||||
|
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
if (command.AttemptId.Value == Guid.Empty || !command.ClientCapabilityFingerprint.IsValid)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Join cancellation invariants are invalid.", nameof(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_available)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|
||||||
|
|| attempt.ClientCapabilityFingerprint != command.ClientCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveAttempt(command.AttemptId);
|
||||||
|
return new(StoreResultCode.Success, true);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
|
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
|
||||||
BindAttemptEndpointCommand command,
|
BindAttemptEndpointCommand command,
|
||||||
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ =>
|
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ =>
|
||||||
@@ -395,7 +507,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
|
public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
|
||||||
MediationHandle handle,
|
MediationHandle handle,
|
||||||
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(_ =>
|
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(now =>
|
||||||
{
|
{
|
||||||
if (!_available)
|
if (!_available)
|
||||||
{
|
{
|
||||||
@@ -419,12 +531,57 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
attempt.IntroductionConsumed = true;
|
attempt.IntroductionConsumed = true;
|
||||||
|
TimeSpan ticketLifetime = TimeSpan.FromTicks(Math.Min(
|
||||||
|
_options.ConnectionTicketLifetime.Ticks,
|
||||||
|
(attempt.Deadline - now).Ticks));
|
||||||
|
attempt.TicketDeadline = now + ticketLifetime;
|
||||||
|
attempt.TicketWallExpiresAt = WallDeadline(now, ticketLifetime);
|
||||||
return new(StoreResultCode.Success, new(
|
return new(StoreResultCode.Success, new(
|
||||||
attempt.Command.AttemptId,
|
Snapshot(attempt),
|
||||||
attempt.HostEndpoint,
|
attempt.HostEndpoint,
|
||||||
attempt.ClientEndpoint));
|
attempt.ClientEndpoint));
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public StoreResult<bool> ConsumeConnectionTicket(
|
||||||
|
ConsumeConnectionTicketCommand command,
|
||||||
|
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
if (command.AttemptId.Value == Guid.Empty || !command.ConnectionTicketFingerprint.IsValid)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Connection ticket invariants are invalid.", nameof(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_available)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|
||||||
|
|| attempt.ConnectionTicketFingerprint != command.ConnectionTicketFingerprint)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attempt.IntroductionConsumed)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.Expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.ConnectionTicketConsumed)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.ReplayRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt.ConnectionTicketConsumed = true;
|
||||||
|
return new(StoreResultCode.Success, true);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
public StoreResult<bool> ConsumeReplay(
|
public StoreResult<bool> ConsumeReplay(
|
||||||
ReplayConsumption consumption,
|
ReplayConsumption consumption,
|
||||||
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
|
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
|
||||||
@@ -662,10 +819,18 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
ListingId = entry.Command.ListingId,
|
ListingId = entry.Command.ListingId,
|
||||||
ClientSubject = entry.Command.ClientSubject,
|
ClientSubject = entry.Command.ClientSubject,
|
||||||
ProtocolVersion = entry.Command.ProtocolVersion,
|
ProtocolVersion = entry.Command.ProtocolVersion,
|
||||||
|
IdempotencyKey = entry.Command.IdempotencyKey,
|
||||||
|
RequestFingerprint = entry.Command.RequestFingerprint,
|
||||||
|
CapabilityDerivationSalt = entry.Command.CapabilityDerivationSalt,
|
||||||
|
HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint,
|
||||||
|
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
|
||||||
|
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
|
||||||
ExpiresAt = entry.WallExpiresAt,
|
ExpiresAt = entry.WallExpiresAt,
|
||||||
|
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
|
||||||
HostEndpoint = entry.HostEndpoint,
|
HostEndpoint = entry.HostEndpoint,
|
||||||
ClientEndpoint = entry.ClientEndpoint,
|
ClientEndpoint = entry.ClientEndpoint,
|
||||||
IntroductionConsumed = entry.IntroductionConsumed,
|
IntroductionConsumed = entry.IntroductionConsumed,
|
||||||
|
ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
||||||
@@ -699,7 +864,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|| listing.CurrentPlayers > listing.MaximumPlayers
|
|| listing.CurrentPlayers > listing.MaximumPlayers
|
||||||
|| !ContractValidation.IsMetadataValid(listing.Metadata)
|
|| !ContractValidation.IsMetadataValid(listing.Metadata)
|
||||||
|| !listing.LeaseFingerprint.IsValid
|
|| !listing.LeaseFingerprint.IsValid
|
||||||
|| !listing.HostPresenceFingerprint.IsValid)
|
|| !listing.HostPresenceFingerprint.IsValid
|
||||||
|
|| !IsDerivationSaltValid(listing.CapabilityDerivationSalt))
|
||||||
{
|
{
|
||||||
throw new ArgumentException("Listing invariants are invalid.", nameof(listing));
|
throw new ArgumentException("Listing invariants are invalid.", nameof(listing));
|
||||||
}
|
}
|
||||||
@@ -736,6 +902,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|| command.ProtocolVersion == 0
|
|| command.ProtocolVersion == 0
|
||||||
|| !command.HostCapabilityFingerprint.IsValid
|
|| !command.HostCapabilityFingerprint.IsValid
|
||||||
|| !command.ClientCapabilityFingerprint.IsValid
|
|| !command.ClientCapabilityFingerprint.IsValid
|
||||||
|
|| !command.ConnectionTicketFingerprint.IsValid
|
||||||
|
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|
||||||
|| command.ScopeAttemptLimit <= 0)
|
|| command.ScopeAttemptLimit <= 0)
|
||||||
{
|
{
|
||||||
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command));
|
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command));
|
||||||
@@ -753,6 +921,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
private static bool IsScopeValid(TenantScope scope) =>
|
private static bool IsScopeValid(TenantScope scope) =>
|
||||||
!string.IsNullOrEmpty(scope.GameId.Value) && !string.IsNullOrEmpty(scope.EnvironmentId.Value);
|
!string.IsNullOrEmpty(scope.GameId.Value) && !string.IsNullOrEmpty(scope.EnvironmentId.Value);
|
||||||
|
|
||||||
|
private static bool IsDerivationSaltValid(string? value) => value is not null
|
||||||
|
&& value.Length == 43
|
||||||
|
&& value.All(static character =>
|
||||||
|
character is >= 'A' and <= 'Z'
|
||||||
|
or >= 'a' and <= 'z'
|
||||||
|
or >= '0' and <= '9'
|
||||||
|
or '-'
|
||||||
|
or '_');
|
||||||
|
|
||||||
private static void ValidateSubject(string subject, string parameterName)
|
private static void ValidateSubject(string subject, string parameterName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(subject) || subject.Length > 256)
|
if (string.IsNullOrWhiteSpace(subject) || subject.Length > 256)
|
||||||
@@ -767,7 +944,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
DateTimeOffset wallExpiresAt,
|
DateTimeOffset wallExpiresAt,
|
||||||
long version)
|
long version)
|
||||||
{
|
{
|
||||||
public ListingDefinition Definition { get; } = definition;
|
public ListingDefinition Definition { get; set; } = definition;
|
||||||
public TimeSpan LeaseDeadline { get; set; } = leaseDeadline;
|
public TimeSpan LeaseDeadline { get; set; } = leaseDeadline;
|
||||||
public DateTimeOffset WallExpiresAt { get; set; } = wallExpiresAt;
|
public DateTimeOffset WallExpiresAt { get; set; } = wallExpiresAt;
|
||||||
public long Version { get; set; } = version;
|
public long Version { get; set; } = version;
|
||||||
@@ -791,11 +968,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
public CreateJoinAttemptCommand Command { get; } = command;
|
public CreateJoinAttemptCommand Command { get; } = command;
|
||||||
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
|
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
|
||||||
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
|
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
|
||||||
|
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
|
||||||
public TimeSpan Deadline { get; } = deadline;
|
public TimeSpan Deadline { get; } = deadline;
|
||||||
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
|
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
|
||||||
|
public TimeSpan? TicketDeadline { get; set; }
|
||||||
|
public DateTimeOffset? TicketWallExpiresAt { get; set; }
|
||||||
public AttemptEndpointBinding? HostEndpoint { get; set; }
|
public AttemptEndpointBinding? HostEndpoint { get; set; }
|
||||||
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
||||||
public bool IntroductionConsumed { get; set; }
|
public bool IntroductionConsumed { get; set; }
|
||||||
|
public bool ConnectionTicketConsumed { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record IdempotencyEntry(
|
private sealed record IdempotencyEntry(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
internal static class StoreResultMapping
|
||||||
|
{
|
||||||
|
public static RendezvousErrorCode ToContractError(this StoreResultCode code) => code switch
|
||||||
|
{
|
||||||
|
StoreResultCode.Success => RendezvousErrorCode.None,
|
||||||
|
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
|
||||||
|
StoreResultCode.Expired => RendezvousErrorCode.Expired,
|
||||||
|
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
|
||||||
|
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
|
||||||
|
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
|
||||||
|
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
|
||||||
|
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
_ => RendezvousErrorCode.InternalError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
@@ -7,10 +10,12 @@ namespace FinalFactory.Rendezvous.Server.Transport;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Owns the cancellable UDP socket used by the future NAT mediator.
|
/// Owns the cancellable UDP socket used by the future NAT mediator.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class UdpMediatorService : BackgroundService
|
internal sealed partial class UdpMediatorService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly ILogger<UdpMediatorService> _logger;
|
private readonly ILogger<UdpMediatorService> _logger;
|
||||||
private readonly UdpMediatorOptions _options;
|
private readonly UdpMediatorOptions _options;
|
||||||
|
private readonly IEphemeralRendezvousStore _store;
|
||||||
|
private readonly ISessionCapabilityService _capabilities;
|
||||||
private UdpClient? _udpClient;
|
private UdpClient? _udpClient;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -18,10 +23,14 @@ public sealed partial class UdpMediatorService : BackgroundService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public UdpMediatorService(
|
public UdpMediatorService(
|
||||||
IOptions<UdpMediatorOptions> options,
|
IOptions<UdpMediatorOptions> options,
|
||||||
ILogger<UdpMediatorService> logger)
|
ILogger<UdpMediatorService> logger,
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_store = store;
|
||||||
|
_capabilities = capabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -81,7 +90,10 @@ public sealed partial class UdpMediatorService : BackgroundService
|
|||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_ = await udpClient.ReceiveAsync(stoppingToken).ConfigureAwait(false);
|
UdpReceiveResult received = await udpClient
|
||||||
|
.ReceiveAsync(stoppingToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ProcessDatagram(received.Buffer, received.RemoteEndPoint, stoppingToken);
|
||||||
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
|
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +111,53 @@ public sealed partial class UdpMediatorService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal UdpPresenceProcessingResult ProcessDatagram(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
IPEndPoint observedSource,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(observedSource);
|
||||||
|
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||||
|
|| datagram is null
|
||||||
|
|| !_capabilities.TryFingerprint(datagram.Capability, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return UdpPresenceProcessingResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datagram.MessageType != UdpPresenceMessageType.HostPresence)
|
||||||
|
{
|
||||||
|
return UdpPresenceProcessingResult.ClientPresenceDeferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressFamilyKind publicFamily = observedSource.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||||
|
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
if (publicFamily == 0)
|
||||||
|
{
|
||||||
|
return UdpPresenceProcessingResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
ObservedEndpoint publicEndpoint = new(
|
||||||
|
publicFamily,
|
||||||
|
observedSource.Address.ToString(),
|
||||||
|
observedSource.Port);
|
||||||
|
ObservedEndpoint localEndpoint = new(
|
||||||
|
datagram.AddressFamily,
|
||||||
|
datagram.LocalAddress,
|
||||||
|
datagram.LocalPort);
|
||||||
|
StoreResult<StoredListing> bound = _store.BindHostPresence(new(
|
||||||
|
datagram.MediationHandle,
|
||||||
|
fingerprint,
|
||||||
|
publicEndpoint,
|
||||||
|
localEndpoint), cancellationToken);
|
||||||
|
return bound.Succeeded
|
||||||
|
? UdpPresenceProcessingResult.HostPresenceAccepted
|
||||||
|
: UdpPresenceProcessingResult.HostPresenceRejected;
|
||||||
|
}
|
||||||
|
|
||||||
[LoggerMessage(
|
[LoggerMessage(
|
||||||
EventId = 1,
|
EventId = 1,
|
||||||
Level = LogLevel.Information,
|
Level = LogLevel.Information,
|
||||||
@@ -114,3 +173,11 @@ public sealed partial class UdpMediatorService : BackgroundService
|
|||||||
Message = "UDP mediator stopped")]
|
Message = "UDP mediator stopped")]
|
||||||
private static partial void LogMediatorStopped(ILogger logger);
|
private static partial void LogMediatorStopped(ILogger logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal enum UdpPresenceProcessingResult
|
||||||
|
{
|
||||||
|
Dropped = 0,
|
||||||
|
HostPresenceAccepted = 1,
|
||||||
|
HostPresenceRejected = 2,
|
||||||
|
ClientPresenceDeferred = 3,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||||
|
|
||||||
|
public sealed class SessionBrowserServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ListEnforcesTenantProtocolPresenceVisibilityAndAvailabilityFilters()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing eligible = fixture.Add();
|
||||||
|
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
|
||||||
|
fixture.Add(scope: new(fixture.Scope.GameId, new("other-env")));
|
||||||
|
fixture.Add(protocolVersion: 8);
|
||||||
|
fixture.Add(regionId: new("us-east"));
|
||||||
|
fixture.Add(visibility: ListingVisibility.Unlisted);
|
||||||
|
fixture.Add(fresh: false);
|
||||||
|
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
|
||||||
|
BrowseSessionsRequest request = fixture.Request();
|
||||||
|
request.ExcludeFull = true;
|
||||||
|
|
||||||
|
BrowserServiceResult<BrowseSessionsResponse> result = fixture.Browser.Browse(request);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.Collection(result.Value!.Items, item => Assert.Equal(eligible.Definition.ListingId, item.ListingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnguessableIdRetrievalAllowsFreshUnlistedOnlyWithinExactScope()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing unlisted = fixture.Add(visibility: ListingVisibility.Unlisted);
|
||||||
|
|
||||||
|
Assert.True(fixture.Browser.Get(
|
||||||
|
unlisted.Definition.ListingId,
|
||||||
|
fixture.Scope.GameId,
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
7).Succeeded);
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||||
|
unlisted.Definition.ListingId,
|
||||||
|
new("other-game"),
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
7).Error);
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||||
|
unlisted.Definition.ListingId,
|
||||||
|
fixture.Scope.GameId,
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
8).Error);
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(20));
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||||
|
unlisted.Definition.ListingId,
|
||||||
|
fixture.Scope.GameId,
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
7).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KeysetCursorReturnsStableRecordsOnceAndRejectsTamperingOrRescoping()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
for (int index = 0; index < 7; index++)
|
||||||
|
{
|
||||||
|
fixture.Add();
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowseSessionsRequest request = fixture.Request(pageSize: 2);
|
||||||
|
List<SessionListingId> seen = [];
|
||||||
|
do
|
||||||
|
{
|
||||||
|
BrowseSessionsResponse page = fixture.Browser.Browse(request).Value!;
|
||||||
|
seen.AddRange(page.Items.Select(static item => item.ListingId));
|
||||||
|
request.Cursor = page.NextCursor;
|
||||||
|
}
|
||||||
|
while (request.Cursor is not null);
|
||||||
|
|
||||||
|
Assert.Equal(7, seen.Count);
|
||||||
|
Assert.Equal(7, seen.Distinct().Count());
|
||||||
|
Assert.Equal(seen.OrderBy(static id => id.Value), seen);
|
||||||
|
|
||||||
|
BrowseSessionsRequest tampered = fixture.Request(pageSize: 2);
|
||||||
|
tampered.Cursor = fixture.Browser.Browse(tampered).Value!.NextCursor + "A";
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(tampered).Error);
|
||||||
|
BrowseSessionsRequest rescoped = fixture.Request(pageSize: 2);
|
||||||
|
rescoped.Cursor = fixture.Browser.Browse(fixture.Request(pageSize: 2)).Value!.NextCursor;
|
||||||
|
rescoped.ExcludeFull = true;
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(rescoped).Error);
|
||||||
|
|
||||||
|
BrowseSessionsRequest expired = fixture.Request(pageSize: 2);
|
||||||
|
expired.Cursor = fixture.Browser.Browse(expired).Value!.NextCursor;
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromMinutes(5));
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(expired).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResponseByteBudgetTrimsLargePagesAndContinuesWithCursor()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
Dictionary<string, string> metadata = Enumerable.Range(0, 14).ToDictionary(
|
||||||
|
static index => $"key-{index}",
|
||||||
|
static index => new string((char)('a' + index % 26), 256),
|
||||||
|
EqualityComparer<string>.Default);
|
||||||
|
for (int index = 0; index < 100; index++)
|
||||||
|
{
|
||||||
|
fixture.Add(metadata: metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
|
||||||
|
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||||
|
|
||||||
|
Assert.InRange(encodedBytes, 1, ContractLimits.BrowserResponseMaxBytes);
|
||||||
|
Assert.NotEmpty(response.Items);
|
||||||
|
Assert.NotNull(response.NextCursor);
|
||||||
|
Assert.True(response.Items.Count < 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresentationMetadataIsJsonEscapedAndResponseHasNoConnectionSecrets()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
fixture.Add(metadata: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mode"] = "co-op",
|
||||||
|
["map"] = "<script>alert(1)</script>",
|
||||||
|
});
|
||||||
|
|
||||||
|
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
|
||||||
|
string json = JsonSerializer.Serialize(response, ContractJson.Options);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("<script>", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("token", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("capability", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal("<script>alert(1)</script>", Assert.Single(response.Items).Metadata["map"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RevokedListingDisappearsBeforeAnotherReadPathCanObserveIt()
|
||||||
|
{
|
||||||
|
using SessionBrowserFixture fixture = new();
|
||||||
|
StoredListing listing = fixture.Add();
|
||||||
|
fixture.Store.RevokeListing(listing.Definition.ListingId);
|
||||||
|
|
||||||
|
Assert.Empty(fixture.Browser.Browse(fixture.Request()).Value!.Items);
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
fixture.Scope.GameId,
|
||||||
|
fixture.Scope.EnvironmentId,
|
||||||
|
7).Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||||
|
|
||||||
|
internal sealed class SessionBrowserFixture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly EphemeralStateFixture _state = new();
|
||||||
|
|
||||||
|
public SessionBrowserFixture()
|
||||||
|
{
|
||||||
|
Cursors = new();
|
||||||
|
Browser = new(_state.Store, Cursors, _state.Clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InMemoryEphemeralRendezvousStore Store => _state.Store;
|
||||||
|
public ManualRendezvousClock Clock => _state.Clock;
|
||||||
|
public SessionBrowserCursorCodec Cursors { get; }
|
||||||
|
public SessionBrowserService Browser { get; }
|
||||||
|
public TenantScope Scope => _state.Scope;
|
||||||
|
|
||||||
|
public StoredListing Add(
|
||||||
|
TenantScope? scope = null,
|
||||||
|
uint protocolVersion = 7,
|
||||||
|
RegionId? regionId = null,
|
||||||
|
ListingVisibility visibility = ListingVisibility.Public,
|
||||||
|
bool fresh = true,
|
||||||
|
int currentPlayers = 1,
|
||||||
|
int maximumPlayers = 8,
|
||||||
|
IReadOnlyDictionary<string, string>? metadata = null)
|
||||||
|
{
|
||||||
|
CreateListingCommand seed = _state.ListingCommand();
|
||||||
|
CreateListingCommand command = seed with
|
||||||
|
{
|
||||||
|
Listing = seed.Listing with
|
||||||
|
{
|
||||||
|
Scope = scope ?? Scope,
|
||||||
|
ProtocolVersion = protocolVersion,
|
||||||
|
RegionId = regionId ?? seed.Listing.RegionId,
|
||||||
|
Visibility = visibility,
|
||||||
|
CurrentPlayers = currentPlayers,
|
||||||
|
MaximumPlayers = maximumPlayers,
|
||||||
|
Metadata = metadata ?? seed.Listing.Metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
StoredListing listing = Store.CreateListing(command).Value!;
|
||||||
|
if (fresh)
|
||||||
|
{
|
||||||
|
listing = Store.BindHostPresence(new(
|
||||||
|
command.Listing.HostPresenceHandle,
|
||||||
|
command.Listing.HostPresenceFingerprint,
|
||||||
|
EphemeralStateFixture.PublicEndpoint(40_000),
|
||||||
|
null)).Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BrowseSessionsRequest Request(int pageSize = 100) => new()
|
||||||
|
{
|
||||||
|
GameId = Scope.GameId,
|
||||||
|
EnvironmentId = Scope.EnvironmentId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
PageSize = pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
public void Dispose() => Cursors.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class ConnectionTicketValidatorTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuthorizedTicketIsAcceptedExactlyOnce()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||||
|
JoinAttemptId attempt = NewAttempt();
|
||||||
|
string ticket = Ticket('A');
|
||||||
|
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||||
|
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Accepted,
|
||||||
|
validator.Consume(attempt, ticket));
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.AlreadyConsumed,
|
||||||
|
validator.Consume(attempt, ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlteredCrossAttemptExpiredAndRevokedTicketsAreRejected()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||||
|
JoinAttemptId first = NewAttempt();
|
||||||
|
JoinAttemptId second = NewAttempt();
|
||||||
|
Assert.True(validator.TryAuthorize(first, Ticket('A'), Now.AddSeconds(20)));
|
||||||
|
Assert.True(validator.TryAuthorize(second, Ticket('B'), Now.AddSeconds(40)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Rejected,
|
||||||
|
validator.Consume(first, Ticket('B')));
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(20));
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Expired,
|
||||||
|
validator.Consume(first, Ticket('A')));
|
||||||
|
Assert.True(validator.Revoke(second));
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Revoked,
|
||||||
|
validator.Consume(second, Ticket('B')));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentConsumptionHasOneWinner()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||||
|
JoinAttemptId attempt = NewAttempt();
|
||||||
|
string ticket = Ticket('C');
|
||||||
|
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||||
|
using ManualResetEventSlim start = new(false);
|
||||||
|
Task<ConnectionTicketConsumptionResult> left = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return validator.Consume(attempt, ticket);
|
||||||
|
});
|
||||||
|
Task<ConnectionTicketConsumptionResult> right = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return validator.Consume(attempt, ticket);
|
||||||
|
});
|
||||||
|
|
||||||
|
start.Set();
|
||||||
|
ConnectionTicketConsumptionResult[] results = await Task.WhenAll(left, right);
|
||||||
|
|
||||||
|
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.Accepted);
|
||||||
|
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.AlreadyConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidatorIsBoundedDisposableAndRedacted()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
ConnectionTicketValidator validator = new(1, clock);
|
||||||
|
Assert.True(validator.TryAuthorize(NewAttempt(), Ticket('A'), Now.AddSeconds(20)));
|
||||||
|
Assert.False(validator.TryAuthorize(NewAttempt(), Ticket('B'), Now.AddSeconds(20)));
|
||||||
|
Assert.DoesNotContain(Ticket('A'), validator.ToString(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
validator.Dispose();
|
||||||
|
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => validator.Revoke(NewAttempt()));
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => validator.Consume(default, string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JoinAttemptId NewAttempt() => new(Guid.NewGuid());
|
||||||
|
private static string Ticket(char value) => new(value, 43);
|
||||||
|
|
||||||
|
private sealed class ManualConnectionTicketClock : IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; set; } = Now;
|
||||||
|
|
||||||
|
public void Advance(TimeSpan duration) => UtcNow += duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientBehaviorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RegistrationRetriesWithTheSameIdempotentPayloadAndDisposesResponses()
|
||||||
|
{
|
||||||
|
TrackingContent unavailable = JsonContent(new ApiError
|
||||||
|
{
|
||||||
|
Code = RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
Message = "try later",
|
||||||
|
RetryAfterSeconds = 1,
|
||||||
|
});
|
||||||
|
TrackingContent created = JsonContent(CreateRegistrationResponse());
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.ServiceUnavailable, unavailable),
|
||||||
|
Response(HttpStatusCode.Created, created),
|
||||||
|
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RegisterSessionRequest request = CreateRegistrationRequest("stable-idempotency-key");
|
||||||
|
RecordingDelay delay = new(() => request.DisplayName = "mutated during retry delay");
|
||||||
|
RendezvousPublisherClient publisher = new(
|
||||||
|
httpClient,
|
||||||
|
new RendezvousClientOptions { JitterRatio = 0 },
|
||||||
|
delay);
|
||||||
|
|
||||||
|
RendezvousClientResult<PublishedSession> result = await publisher.RegisterAsync(
|
||||||
|
request,
|
||||||
|
"publisher-credential");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, handler.RequestBodies.Count);
|
||||||
|
Assert.Equal(handler.RequestBodies[0], handler.RequestBodies[1]);
|
||||||
|
Assert.Contains("stable-idempotency-key", handler.RequestBodies[0], StringComparison.Ordinal);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(1), Assert.Single(delay.Delays));
|
||||||
|
Assert.True(unavailable.IsDisposed);
|
||||||
|
Assert.True(created.IsDisposed);
|
||||||
|
|
||||||
|
using HttpResponseMessage stillOwnedByCaller = await httpClient.GetAsync("health");
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, stillOwnedByCaller.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateUsesTheLeaseWithoutMutatingTheCallersRequest()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
|
||||||
|
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousPublisherClient publisher = new(httpClient);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistrationRequest("update-idempotency-key"),
|
||||||
|
"publisher-credential"));
|
||||||
|
UpdateSessionRequest update = new()
|
||||||
|
{
|
||||||
|
LeaseToken = "caller-placeholder",
|
||||||
|
BuildVersion = "2.0.0",
|
||||||
|
DisplayName = "updated",
|
||||||
|
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 4 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
};
|
||||||
|
|
||||||
|
RendezvousClientResult<bool> result = await publisher.UpdateAsync(
|
||||||
|
session,
|
||||||
|
update,
|
||||||
|
"publisher-credential");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("caller-placeholder", update.LeaseToken);
|
||||||
|
Assert.Contains("lease-token", handler.RequestBodies[1], StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("caller-placeholder", handler.RequestBodies[1], StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GatewayFailureIsRetriedForSafeBrowserReads()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
new HttpResponseMessage(HttpStatusCode.BadGateway),
|
||||||
|
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse())));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RecordingDelay delay = new();
|
||||||
|
RendezvousSessionBrowserClient browser = new(
|
||||||
|
httpClient,
|
||||||
|
new RendezvousClientOptions { JitterRatio = 0 },
|
||||||
|
delay);
|
||||||
|
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> result = await browser.BrowseAsync(new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
Assert.Equal(2, handler.RequestUris.Count);
|
||||||
|
Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SuccessResultRequiresAValue()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => RendezvousClientResult.Success<string>(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BrowseAllFollowsCursorsWithoutMutatingTheCallersRequest()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
|
||||||
|
{
|
||||||
|
Items = [CreateListing("00000000-0000-0000-0000-000000000001")],
|
||||||
|
NextCursor = "next page+token",
|
||||||
|
})),
|
||||||
|
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
|
||||||
|
{
|
||||||
|
Items = [CreateListing("00000000-0000-0000-0000-000000000002")],
|
||||||
|
})));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||||
|
BrowseSessionsRequest request = new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
PageSize = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(request);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value!.Count);
|
||||||
|
Assert.Null(request.Cursor);
|
||||||
|
Assert.DoesNotContain("cursor=", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("cursor=next%20page%2Btoken", handler.RequestUris[1].Query, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LeaseMaintainerReportsLeaseLoss()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
|
||||||
|
Response(HttpStatusCode.Gone, JsonContent(new ApiError
|
||||||
|
{
|
||||||
|
Code = RendezvousErrorCode.Expired,
|
||||||
|
Message = "lease expired",
|
||||||
|
})));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RecordingDelay delay = new();
|
||||||
|
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistrationRequest("lease-loss-key"),
|
||||||
|
"publisher-credential"));
|
||||||
|
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||||
|
session,
|
||||||
|
"publisher-credential");
|
||||||
|
bool eventRaised = false;
|
||||||
|
maintainer.LeaseLost += (_, _) => eventRaised = true;
|
||||||
|
|
||||||
|
LeaseMaintenanceResult result = await maintainer.RunAsync();
|
||||||
|
|
||||||
|
Assert.Equal(LeaseMaintenanceStopReason.LeaseLost, result.Reason);
|
||||||
|
Assert.Equal(RendezvousErrorCode.Expired, result.Error);
|
||||||
|
Assert.True(eventRaised);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(15), Assert.Single(delay.Delays));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposingLeaseMaintainerCancelsItsWaitAndDoesNotRenew()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
BlockingDelay delay = new();
|
||||||
|
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistrationRequest("dispose-key"),
|
||||||
|
"publisher-credential"));
|
||||||
|
SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||||
|
session,
|
||||||
|
"publisher-credential");
|
||||||
|
Task<LeaseMaintenanceResult> active = maintainer.RunAsync();
|
||||||
|
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await maintainer.DisposeAsync();
|
||||||
|
LeaseMaintenanceResult result = await active;
|
||||||
|
|
||||||
|
Assert.Equal(LeaseMaintenanceStopReason.Disposed, result.Reason);
|
||||||
|
Assert.Single(handler.RequestUris);
|
||||||
|
await Assert.ThrowsAsync<ObjectDisposedException>(() => maintainer.RunAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallerCancellationStopsLeaseMaintenanceWithoutRenewing()
|
||||||
|
{
|
||||||
|
ScriptedHandler handler = new(
|
||||||
|
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
|
||||||
|
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
BlockingDelay delay = new();
|
||||||
|
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistrationRequest("cancel-key"),
|
||||||
|
"publisher-credential"));
|
||||||
|
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||||
|
session,
|
||||||
|
"publisher-credential");
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
Task<LeaseMaintenanceResult> active = maintainer.RunAsync(cancellation.Token);
|
||||||
|
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await cancellation.CancelAsync();
|
||||||
|
LeaseMaintenanceResult result = await active;
|
||||||
|
|
||||||
|
Assert.Equal(LeaseMaintenanceStopReason.Cancelled, result.Reason);
|
||||||
|
Assert.Single(handler.RequestUris);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
return Assert.IsType<T>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CreateRegistrationRequest(string idempotencyKey) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = idempotencyKey,
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "SDK host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RegisterSessionResponse CreateRegistrationResponse() => new()
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000010")),
|
||||||
|
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000011")),
|
||||||
|
LeaseToken = "lease-token",
|
||||||
|
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000012")),
|
||||||
|
HostPresenceCapability = "presence-capability",
|
||||||
|
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||||
|
LeaseRenewAfterSeconds = 15,
|
||||||
|
HostPresenceRefreshAfterSeconds = 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SessionListing CreateListing(string id) => new()
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.Parse(id)),
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
PublisherTrustMode = PublisherTrustMode.ManagedDedicated,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static TrackingContent JsonContent<T>(T value) => new(
|
||||||
|
JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options));
|
||||||
|
|
||||||
|
private static HttpResponseMessage Response(HttpStatusCode status, HttpContent content) => new(status)
|
||||||
|
{
|
||||||
|
Content = content,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class ScriptedHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||||
|
|
||||||
|
internal List<string> RequestBodies { get; } = [];
|
||||||
|
internal List<Uri> RequestUris { get; } = [];
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
RequestUris.Add(request.RequestUri!);
|
||||||
|
RequestBodies.Add(request.Content is null
|
||||||
|
? string.Empty
|
||||||
|
: await request.Content.ReadAsStringAsync(cancellationToken));
|
||||||
|
return _responses.Count > 0
|
||||||
|
? _responses.Dequeue()
|
||||||
|
: throw new InvalidOperationException("No scripted response remains.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TrackingContent(byte[] bytes) : HttpContent
|
||||||
|
{
|
||||||
|
internal bool IsDisposed { get; private set; }
|
||||||
|
|
||||||
|
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||||
|
stream.WriteAsync(bytes).AsTask();
|
||||||
|
|
||||||
|
protected override bool TryComputeLength(out long length)
|
||||||
|
{
|
||||||
|
length = bytes.Length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
IsDisposed = true;
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingDelay(Action? onDelay = null) : IRendezvousDelay
|
||||||
|
{
|
||||||
|
internal List<TimeSpan> Delays { get; } = [];
|
||||||
|
|
||||||
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
Delays.Add(delay);
|
||||||
|
onDelay?.Invoke();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingDelay : IRendezvousDelay
|
||||||
|
{
|
||||||
|
internal TaskCompletionSource Started { get; } = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Started.TrySetResult();
|
||||||
|
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientIntegrationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
|
||||||
|
{
|
||||||
|
await using ClientTestHost host = await ClientTestHost.StartAsync();
|
||||||
|
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||||
|
RendezvousSessionBrowserClient browser = new(host.HttpClient);
|
||||||
|
List<PublishedSession> sessions = [];
|
||||||
|
|
||||||
|
for (int index = 0; index < 3; index++)
|
||||||
|
{
|
||||||
|
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(index),
|
||||||
|
host.PublisherCredential);
|
||||||
|
PublishedSession session = AssertSuccess(registered);
|
||||||
|
sessions.Add(session);
|
||||||
|
Assert.True(host.Capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint fingerprint));
|
||||||
|
StoreResult<StoredListing> bound = host.Store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
fingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, $"203.0.113.{80 + index}", 41_000 + index),
|
||||||
|
null));
|
||||||
|
Assert.Equal(StoreResultCode.Success, bound.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
PublishedSession first = sessions[0];
|
||||||
|
RendezvousClientResult<RenewLeaseResponse> renewed = await publisher.RenewAsync(
|
||||||
|
first,
|
||||||
|
host.PublisherCredential);
|
||||||
|
Assert.True(renewed.IsSuccess, renewed.Message);
|
||||||
|
Assert.Equal(renewed.Value!.ExpiresAt, first.ExpiresAt);
|
||||||
|
|
||||||
|
UpdateSessionRequest update = new()
|
||||||
|
{
|
||||||
|
BuildVersion = "2.0.0",
|
||||||
|
DisplayName = "SDK host updated",
|
||||||
|
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
};
|
||||||
|
RendezvousClientResult<bool> updated = await publisher.UpdateAsync(
|
||||||
|
first,
|
||||||
|
update,
|
||||||
|
host.PublisherCredential);
|
||||||
|
Assert.True(updated.IsSuccess, updated.Message);
|
||||||
|
|
||||||
|
BrowseSessionsRequest browseRequest = new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
PageSize = 1,
|
||||||
|
ExcludeFull = true,
|
||||||
|
};
|
||||||
|
IReadOnlyList<SessionListing> listings = AssertSuccess(
|
||||||
|
await browser.BrowseAllAsync(browseRequest));
|
||||||
|
Assert.Equal(3, listings.Count);
|
||||||
|
Assert.Equal("SDK host updated", listings.Single(item => item.ListingId == first.ListingId).DisplayName);
|
||||||
|
|
||||||
|
GetSessionResponse direct = AssertSuccess(await browser.GetAsync(
|
||||||
|
first.ListingId,
|
||||||
|
new("space-game"),
|
||||||
|
new("production"),
|
||||||
|
7));
|
||||||
|
Assert.Equal("2.0.0", direct.Session.BuildVersion);
|
||||||
|
|
||||||
|
foreach (PublishedSession session in sessions)
|
||||||
|
{
|
||||||
|
RendezvousClientResult<bool> deregistered = await publisher.DeregisterAsync(
|
||||||
|
session,
|
||||||
|
host.PublisherCredential);
|
||||||
|
Assert.True(deregistered.IsSuccess, deregistered.Message);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(session.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
return Assert.IsAssignableFrom<T>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CreateRegistration(int index) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = $"sdk-integration-{index}",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = $"SDK host {index}",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class ClientTestHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _application;
|
||||||
|
|
||||||
|
private ClientTestHost(
|
||||||
|
WebApplication application,
|
||||||
|
HttpClient httpClient,
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
EphemeralCapabilityIssuer capabilities,
|
||||||
|
string publisherCredential)
|
||||||
|
{
|
||||||
|
_application = application;
|
||||||
|
HttpClient = httpClient;
|
||||||
|
Store = store;
|
||||||
|
Capabilities = capabilities;
|
||||||
|
PublisherCredential = publisherCredential;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HttpClient HttpClient { get; }
|
||||||
|
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||||
|
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||||
|
internal string PublisherCredential { get; }
|
||||||
|
|
||||||
|
internal static async Task<ClientTestHost> StartAsync()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
|
EphemeralStoreOptions stateOptions = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||||
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
|
ProvisioningTestData.CreateOptions(),
|
||||||
|
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||||
|
clock.UtcNow);
|
||||||
|
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||||
|
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||||
|
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
|
options.ThrowOnBadRequest = true);
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||||
|
builder.Services.AddSingleton(provisioning);
|
||||||
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(clock);
|
||||||
|
builder.Services.AddSingleton(capabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||||
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.MapRendezvousContractEndpoints();
|
||||||
|
await app.StartAsync();
|
||||||
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
|
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
|
return new(
|
||||||
|
app,
|
||||||
|
new HttpClient { BaseAddress = new Uri(address) },
|
||||||
|
store,
|
||||||
|
capabilities,
|
||||||
|
credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
HttpClient.Dispose();
|
||||||
|
await _application.StopAsync();
|
||||||
|
await _application.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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}",
|
||||||
@@ -64,5 +65,44 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||||
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|
||||||
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
JsonElement publisherBearer = root.GetProperty("components")
|
||||||
|
.GetProperty("securitySchemes")
|
||||||
|
.GetProperty("PublisherBearer");
|
||||||
|
Assert.Equal("http", publisherBearer.GetProperty("type").GetString());
|
||||||
|
Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString());
|
||||||
|
(string Path, string Method)[] publisherOperations =
|
||||||
|
[
|
||||||
|
("/v1/sessions", "post"),
|
||||||
|
("/v1/sessions/{listingId}", "put"),
|
||||||
|
("/v1/sessions/{listingId}", "delete"),
|
||||||
|
("/v1/sessions/{listingId}/renew", "post"),
|
||||||
|
];
|
||||||
|
foreach ((string operationPath, string method) in publisherOperations)
|
||||||
|
{
|
||||||
|
JsonElement security = root.GetProperty("paths")
|
||||||
|
.GetProperty(operationPath)
|
||||||
|
.GetProperty(method)
|
||||||
|
.GetProperty("security");
|
||||||
|
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonElement cancelParameters = root.GetProperty("paths")
|
||||||
|
.GetProperty("/v1/join-attempts/{attemptId}")
|
||||||
|
.GetProperty("delete")
|
||||||
|
.GetProperty("parameters");
|
||||||
|
JsonElement cancelCapability = Assert.Single(cancelParameters.EnumerateArray(), static parameter =>
|
||||||
|
parameter.GetProperty("in").GetString() == "header"
|
||||||
|
&& parameter.GetProperty("name").GetString()
|
||||||
|
== "X-Rendezvous-Client-Punch-Capability");
|
||||||
|
Assert.True(cancelCapability.GetProperty("required").GetBoolean());
|
||||||
|
JsonElement hostPollParameters = root.GetProperty("paths")
|
||||||
|
.GetProperty("/v1/sessions/{listingId}/join-attempts")
|
||||||
|
.GetProperty("get")
|
||||||
|
.GetProperty("parameters");
|
||||||
|
JsonElement leaseToken = Assert.Single(hostPollParameters.EnumerateArray(), static parameter =>
|
||||||
|
parameter.GetProperty("in").GetString() == "header"
|
||||||
|
&& parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token");
|
||||||
|
Assert.True(leaseToken.GetProperty("required").GetBoolean());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,204 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
public sealed class JoinAttemptHttpEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientCreatesHostPollsAndCapabilityCancelsAnAttemptOverHttp()
|
||||||
|
{
|
||||||
|
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||||
|
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(),
|
||||||
|
host.PublisherCredential));
|
||||||
|
Assert.True(host.Capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint presenceFingerprint));
|
||||||
|
Assert.True(host.Store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
presenceFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
CreateJoinAttemptRequest request = new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "http-join-1",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ListingId = session.ListingId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
|
||||||
|
"v1/join-attempts",
|
||||||
|
request,
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
|
||||||
|
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
|
||||||
|
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
|
||||||
|
|
||||||
|
using HttpRequestMessage pollRequest = new(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||||
|
pollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||||
|
using HttpResponseMessage pollResponse = await host.HttpClient.SendAsync(pollRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, pollResponse.StatusCode);
|
||||||
|
BrowseHostJoinAttemptsResponse polled = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||||
|
await pollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||||
|
HostJoinAttempt hostAttempt = Assert.Single(polled.Items);
|
||||||
|
Assert.Equal(created.AttemptId, hostAttempt.AttemptId);
|
||||||
|
Assert.NotEqual(created.ClientPunchCapability, hostAttempt.HostPunchCapability);
|
||||||
|
|
||||||
|
using HttpResponseMessage missingCapability = await host.HttpClient.DeleteAsync(
|
||||||
|
$"v1/join-attempts/{created.AttemptId}");
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, missingCapability.StatusCode);
|
||||||
|
ApiError missingCapabilityError = Assert.IsType<ApiError>(
|
||||||
|
await missingCapability.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, missingCapabilityError.Code);
|
||||||
|
|
||||||
|
using HttpRequestMessage unauthorizedCancel = new(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/join-attempts/{created.AttemptId}");
|
||||||
|
unauthorizedCancel.Headers.Add(
|
||||||
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||||
|
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedCancel);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
|
||||||
|
|
||||||
|
using HttpRequestMessage cancelRequest = new(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/join-attempts/{created.AttemptId}");
|
||||||
|
cancelRequest.Headers.Add(
|
||||||
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
|
created.ClientPunchCapability);
|
||||||
|
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
|
||||||
|
|
||||||
|
using HttpRequestMessage emptyPollRequest = new(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||||
|
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||||
|
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest);
|
||||||
|
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||||
|
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||||
|
Assert.Empty(empty.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
return Assert.IsAssignableFrom<T>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CreateRegistration() => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "join-http-host",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "Join HTTP host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class JoinHttpTestHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _application;
|
||||||
|
|
||||||
|
private JoinHttpTestHost(
|
||||||
|
WebApplication application,
|
||||||
|
HttpClient httpClient,
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
EphemeralCapabilityIssuer capabilities,
|
||||||
|
string publisherCredential)
|
||||||
|
{
|
||||||
|
_application = application;
|
||||||
|
HttpClient = httpClient;
|
||||||
|
Store = store;
|
||||||
|
Capabilities = capabilities;
|
||||||
|
PublisherCredential = publisherCredential;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HttpClient HttpClient { get; }
|
||||||
|
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||||
|
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||||
|
internal string PublisherCredential { get; }
|
||||||
|
|
||||||
|
internal static async Task<JoinHttpTestHost> StartAsync()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
|
EphemeralStoreOptions stateOptions = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||||
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
|
ProvisioningTestData.CreateOptions(),
|
||||||
|
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||||
|
clock.UtcNow);
|
||||||
|
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||||
|
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||||
|
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
|
options.ThrowOnBadRequest = true);
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||||
|
builder.Services.AddSingleton(provisioning);
|
||||||
|
builder.Services.AddSingleton(provisioning.Policies);
|
||||||
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(clock);
|
||||||
|
builder.Services.AddSingleton(capabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||||
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.MapRendezvousContractEndpoints();
|
||||||
|
await app.StartAsync();
|
||||||
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
|
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
|
return new(
|
||||||
|
app,
|
||||||
|
new HttpClient { BaseAddress = new Uri(address) },
|
||||||
|
store,
|
||||||
|
capabilities,
|
||||||
|
credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
HttpClient.Dispose();
|
||||||
|
await _application.StopAsync();
|
||||||
|
await _application.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
public sealed class JoinAttemptServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreateIsIdempotentAndScopesDistinctRoleCredentials()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "stable-join-key");
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> first = fixture.Service.Create(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
request);
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> replay = fixture.Service.Create(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
request);
|
||||||
|
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.True(replay.Succeeded);
|
||||||
|
Assert.Equal(first.Value!.AttemptId, replay.Value!.AttemptId);
|
||||||
|
Assert.Equal(first.Value.MediationHandle, replay.Value.MediationHandle);
|
||||||
|
Assert.Equal(first.Value.ClientPunchCapability, replay.Value.ClientPunchCapability);
|
||||||
|
Assert.True(ContractValidation.IsCapabilityValid(first.Value.ClientPunchCapability));
|
||||||
|
Assert.InRange(
|
||||||
|
first.Value.ClientPunchCapability.Length,
|
||||||
|
1,
|
||||||
|
ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||||
|
|
||||||
|
HostJoinAttempt host = Assert.Single(fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
10,
|
||||||
|
null).Value!.Items);
|
||||||
|
Assert.NotEqual(host.HostPunchCapability, first.Value.ClientPunchCapability);
|
||||||
|
Assert.DoesNotContain(first.Value.ClientPunchCapability, fixture.Sessions.Store.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameIdempotencyKeyWithDifferentRequestConflicts()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "reused-key");
|
||||||
|
Assert.True(fixture.Service.Create(fixture.ClientSubject, request).Succeeded);
|
||||||
|
|
||||||
|
request.ListingId = other.ListingId;
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> conflict = fixture.Service.Create(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
request);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousErrorCode.Conflict, conflict.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreationRejectsStaleIncompatibleAndCrossTenantListings()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
|
||||||
|
|
||||||
|
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest incompatible = fixture.Request(active.ListingId);
|
||||||
|
incompatible.ProtocolVersion = 8;
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, incompatible).Error);
|
||||||
|
|
||||||
|
CreateJoinAttemptRequest otherTenant = fixture.Request(active.ListingId);
|
||||||
|
otherTenant.GameId = new("other-game");
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
fixture.Create(registration.ListingId);
|
||||||
|
fixture.Create(registration.ListingId);
|
||||||
|
fixture.Create(registration.ListingId);
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> first = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
1,
|
||||||
|
null);
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.Single(first.Value!.Items);
|
||||||
|
Assert.NotNull(first.Value.NextCursor);
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> second = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
1,
|
||||||
|
first.Value.NextCursor);
|
||||||
|
Assert.True(second.Succeeded);
|
||||||
|
Assert.NotEqual(first.Value.Items[0].AttemptId, second.Value!.Items[0].AttemptId);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
1,
|
||||||
|
null).Error);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.InvalidRequest,
|
||||||
|
fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
1,
|
||||||
|
first.Value.NextCursor + "x").Error);
|
||||||
|
|
||||||
|
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.InvalidRequest,
|
||||||
|
fixture.Service.BrowseForHost(
|
||||||
|
other.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
other.LeaseToken,
|
||||||
|
1,
|
||||||
|
first.Value.NextCursor).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationRequiresTheAttemptsClientCapabilityAndRevokesState()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Error);
|
||||||
|
Assert.True(fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
created.ClientPunchCapability).Succeeded);
|
||||||
|
Assert.Empty(fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
10,
|
||||||
|
null).Value!.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoleAndAttemptCapabilitiesCannotCrossWireConcurrentAttempts()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
||||||
|
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
||||||
|
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
second.ClientPunchCapability,
|
||||||
|
out SecretFingerprint secondClientFingerprint));
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
first.ClientPunchCapability,
|
||||||
|
out SecretFingerprint firstClientFingerprint));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
firstStored.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
secondClientFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "198.51.100.20", 42_000),
|
||||||
|
null)).Code);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
firstStored.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
firstClientFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
|
||||||
|
null)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectionTicketIsDistinctExpiringAndAtomicallySingleUse()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
JoinAttemptServiceResult<ConnectionTicketGrant> issued = fixture.Service.IssueConnectionTicket(
|
||||||
|
introduction.Attempt);
|
||||||
|
Assert.True(issued.Succeeded);
|
||||||
|
Assert.True(ContractValidation.IsConnectionTicketValid(issued.Value!.Ticket));
|
||||||
|
Assert.NotEqual(created.ClientPunchCapability, issued.Value.Ticket);
|
||||||
|
Assert.DoesNotContain(issued.Value.Ticket, issued.Value.ToString(), StringComparison.Ordinal);
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
issued.Value.Ticket,
|
||||||
|
out SecretFingerprint ticketFingerprint));
|
||||||
|
ConsumeConnectionTicketCommand command = new(created.AttemptId, ticketFingerprint);
|
||||||
|
using ManualResetEventSlim start = new(false);
|
||||||
|
|
||||||
|
Task<StoreResult<bool>> left = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
||||||
|
});
|
||||||
|
Task<StoreResult<bool>> right = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
||||||
|
});
|
||||||
|
start.Set();
|
||||||
|
StoreResult<bool>[] results = await Task.WhenAll(left, right);
|
||||||
|
|
||||||
|
Assert.Single(results, static result => result.Succeeded);
|
||||||
|
Assert.Single(results, static result => result.Code == StoreResultCode.ReplayRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TicketRejectsAlteredCrossAttemptPreIntroductionAndExpiry()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new()
|
||||||
|
{
|
||||||
|
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
};
|
||||||
|
using JoinAttemptFixture fixture = new(options);
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
||||||
|
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
||||||
|
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
||||||
|
StoredJoinAttempt secondStored = fixture.GetAttempt(registration, second.AttemptId);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.Conflict,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
first.AttemptId,
|
||||||
|
firstStored.ConnectionTicketFingerprint)).Code);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
second.AttemptId,
|
||||||
|
firstStored.ConnectionTicketFingerprint)).Code);
|
||||||
|
|
||||||
|
fixture.Introduce(registration, first);
|
||||||
|
fixture.Sessions.Clock.Advance(options.ConnectionTicketLifetime);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.Expired,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
first.AttemptId,
|
||||||
|
firstStored.ConnectionTicketFingerprint)).Code);
|
||||||
|
Assert.False(secondStored.ConnectionTicketConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TicketWindowBeginsAtIntroductionAndNeverOutlivesTheAttempt()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
ConnectionTicketGrant ticket = Assert.IsType<ConnectionTicketGrant>(
|
||||||
|
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
|
||||||
|
|
||||||
|
Assert.Equal(created.ExpiresAt, ticket.ExpiresAt);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(15), ticket.ExpiresAt - fixture.Sessions.Clock.UtcNow);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
introduction.Attempt.CapabilityDerivationSalt,
|
||||||
|
introduction.Attempt.ToString(),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Sessions;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptFixture : IDisposable
|
||||||
|
{
|
||||||
|
private int _sequence;
|
||||||
|
|
||||||
|
public JoinAttemptFixture(EphemeralStoreOptions? options = null)
|
||||||
|
{
|
||||||
|
Sessions = new(options);
|
||||||
|
Cursors = new();
|
||||||
|
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
|
||||||
|
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
|
||||||
|
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionLeaseFixture Sessions { get; }
|
||||||
|
public JoinAttemptCursorCodec Cursors { get; }
|
||||||
|
public JoinAttemptService Service { get; }
|
||||||
|
public string ClientSubject { get; }
|
||||||
|
|
||||||
|
public (RegisterSessionResponse Registration, StoredListing Listing) CreateHost(bool bindPresence = true)
|
||||||
|
{
|
||||||
|
RegisterSessionResponse registration = Sessions.Register();
|
||||||
|
if (bindPresence)
|
||||||
|
{
|
||||||
|
Assert.True(Sessions.BindPresence(registration).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing listing = Sessions.Store.GetListing(registration.ListingId, false).Value!;
|
||||||
|
return (registration, listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreateJoinAttemptRequest Request(
|
||||||
|
SessionListingId listingId,
|
||||||
|
string? idempotencyKey = null) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = idempotencyKey ?? $"join-{Interlocked.Increment(ref _sequence)}",
|
||||||
|
GameId = Sessions.Scope.GameId,
|
||||||
|
EnvironmentId = Sessions.Scope.EnvironmentId,
|
||||||
|
ListingId = listingId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
public CreateJoinAttemptResponse Create(
|
||||||
|
SessionListingId listingId,
|
||||||
|
string? idempotencyKey = null)
|
||||||
|
{
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = Service.Create(
|
||||||
|
ClientSubject,
|
||||||
|
Request(listingId, idempotencyKey));
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
return Assert.IsType<CreateJoinAttemptResponse>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredJoinAttempt GetAttempt(
|
||||||
|
RegisterSessionResponse registration,
|
||||||
|
JoinAttemptId attemptId)
|
||||||
|
{
|
||||||
|
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||||
|
registration.LeaseToken,
|
||||||
|
out SecretFingerprint leaseFingerprint));
|
||||||
|
IReadOnlyList<StoredJoinAttempt> attempts = Sessions.Store.BrowseHostJoinAttempts(new(
|
||||||
|
registration.ListingId,
|
||||||
|
leaseFingerprint,
|
||||||
|
ContractLimits.BrowserPageMaxItems)).Value!;
|
||||||
|
return attempts.Single(attempt => attempt.AttemptId == attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IntroductionEndpoints Introduce(
|
||||||
|
RegisterSessionResponse registration,
|
||||||
|
CreateJoinAttemptResponse created)
|
||||||
|
{
|
||||||
|
StoredJoinAttempt attempt = GetAttempt(registration, created.AttemptId);
|
||||||
|
HostJoinAttempt host = Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||||
|
host.HostPunchCapability,
|
||||||
|
out SecretFingerprint hostFingerprint));
|
||||||
|
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||||
|
created.ClientPunchCapability,
|
||||||
|
out SecretFingerprint clientFingerprint));
|
||||||
|
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
hostFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
clientFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "198.51.100.40", 42_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
StoreResult<IntroductionEndpoints> introduced = Sessions.Store.ConsumeIntroduction(
|
||||||
|
attempt.MediationHandle);
|
||||||
|
Assert.True(introduced.Succeeded);
|
||||||
|
return introduced.Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Cursors.Dispose();
|
||||||
|
Sessions.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
using FinalFactory.Rendezvous.Server.Transport;
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -7,6 +12,39 @@ namespace FinalFactory.Rendezvous.Tests.Server;
|
|||||||
|
|
||||||
public sealed class UdpMediatorServiceTests
|
public sealed class UdpMediatorServiceTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AuthenticatedHostDatagramGatesVisibilityUsingObservedGameplaySocket()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions { ListenAddress = "127.0.0.1", Port = 0 }),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
fixture.Store,
|
||||||
|
fixture.Capabilities);
|
||||||
|
PresenceDatagram presence = new()
|
||||||
|
{
|
||||||
|
MessageType = UdpPresenceMessageType.HostPresence,
|
||||||
|
MediationHandle = registration.HostPresenceHandle,
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
LocalAddress = "192.168.1.50",
|
||||||
|
LocalPort = 40_000,
|
||||||
|
Capability = registration.HostPresenceCapability,
|
||||||
|
};
|
||||||
|
IPEndPoint observedGameplaySocket = new(IPAddress.Parse("203.0.113.77"), 51_234);
|
||||||
|
presence.Capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||||
|
Assert.Equal(
|
||||||
|
UdpPresenceProcessingResult.HostPresenceRejected,
|
||||||
|
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
||||||
|
Assert.Empty(fixture.Browse());
|
||||||
|
|
||||||
|
presence.Capability = registration.HostPresenceCapability;
|
||||||
|
Assert.Equal(
|
||||||
|
UdpPresenceProcessingResult.HostPresenceAccepted,
|
||||||
|
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
||||||
|
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
|
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
|
||||||
{
|
{
|
||||||
@@ -16,9 +54,14 @@ public sealed class UdpMediatorServiceTests
|
|||||||
ListenAddress = IPAddress.Loopback.ToString(),
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
Port = 0,
|
Port = 0,
|
||||||
};
|
};
|
||||||
|
ManualRendezvousClock clock = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock);
|
||||||
|
using EphemeralCapabilityIssuer capabilities = new();
|
||||||
using UdpMediatorService service = new(
|
using UdpMediatorService service = new(
|
||||||
Options.Create(options),
|
Options.Create(options),
|
||||||
NullLogger<UdpMediatorService>.Instance);
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
store,
|
||||||
|
capabilities);
|
||||||
|
|
||||||
await service.StartAsync(timeout.Token);
|
await service.StartAsync(timeout.Token);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionHttpEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticatedHttpLifecycleReturnsStableContractsAndStatuses()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
|
EphemeralStoreOptions stateOptions = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||||
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
|
ProvisioningTestData.CreateOptions(),
|
||||||
|
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||||
|
clock.UtcNow);
|
||||||
|
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||||
|
string publisherCredential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||||
|
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
|
options.ThrowOnBadRequest = true);
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||||
|
builder.Services.AddSingleton(provisioning);
|
||||||
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(clock);
|
||||||
|
builder.Services.AddSingleton(capabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||||
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
await using WebApplication app = builder.Build();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.MapRendezvousContractEndpoints();
|
||||||
|
await app.StartAsync();
|
||||||
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
|
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
|
using HttpClient client = new() { BaseAddress = new Uri(address) };
|
||||||
|
RegisterSessionRequest registration = new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "http-register-1",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.4.2",
|
||||||
|
DisplayName = "HTTP host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mode"] = "co-op",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpResponseMessage unauthenticated = await client.PostAsJsonAsync(
|
||||||
|
"/v1/sessions",
|
||||||
|
registration,
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
|
||||||
|
Assert.Equal("Bearer", Assert.Single(unauthenticated.Headers.WwwAuthenticate).Scheme);
|
||||||
|
ApiError? authenticationError = await unauthenticated.Content.ReadFromJsonAsync<ApiError>(
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(RendezvousErrorCode.AuthenticationRequired, authenticationError!.Code);
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||||
|
"Bearer",
|
||||||
|
publisherCredential);
|
||||||
|
string invalidJson = JsonSerializer.Serialize(registration, ContractJson.Options)
|
||||||
|
.Replace("\"public\"", "\"futureVisibility\"", StringComparison.Ordinal);
|
||||||
|
HttpResponseMessage invalid = await client.PostAsync(
|
||||||
|
"/v1/sessions",
|
||||||
|
new StringContent(invalidJson, Encoding.UTF8, "application/json"));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||||
|
ApiError? invalidError = await invalid.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options);
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, invalidError!.Code);
|
||||||
|
|
||||||
|
HttpResponseMessage created = await client.PostAsJsonAsync(
|
||||||
|
"/v1/sessions",
|
||||||
|
registration,
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||||
|
RegisterSessionResponse? session = await created.Content.ReadFromJsonAsync<RegisterSessionResponse>(
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.NotNull(session);
|
||||||
|
Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString);
|
||||||
|
Assert.True(capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint presenceFingerprint));
|
||||||
|
store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
presenceFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||||
|
null));
|
||||||
|
|
||||||
|
BrowseSessionsResponse? browser = await client.GetFromJsonAsync<BrowseSessionsResponse>(
|
||||||
|
"/v1/sessions?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7®ionId=eu-central&pageSize=10&excludeFull=true",
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(session.ListingId, Assert.Single(browser!.Items).ListingId);
|
||||||
|
GetSessionResponse? direct = await client.GetFromJsonAsync<GetSessionResponse>(
|
||||||
|
$"/v1/sessions/{session.ListingId}?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7",
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(session.ListingId, direct!.Session.ListingId);
|
||||||
|
|
||||||
|
HttpResponseMessage renewed = await client.PostAsJsonAsync(
|
||||||
|
$"/v1/sessions/{session.ListingId}/renew",
|
||||||
|
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, renewed.StatusCode);
|
||||||
|
Assert.NotNull(await renewed.Content.ReadFromJsonAsync<RenewLeaseResponse>(ContractJson.Options));
|
||||||
|
|
||||||
|
HttpResponseMessage updated = await client.PutAsJsonAsync(
|
||||||
|
$"/v1/sessions/{session.ListingId}",
|
||||||
|
new UpdateSessionRequest
|
||||||
|
{
|
||||||
|
LeaseToken = session.LeaseToken,
|
||||||
|
BuildVersion = "1.4.3",
|
||||||
|
DisplayName = "HTTP host updated",
|
||||||
|
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
|
||||||
|
},
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
|
||||||
|
|
||||||
|
using HttpRequestMessage deleteRequest = new(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"/v1/sessions/{session.ListingId}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(
|
||||||
|
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
|
||||||
|
options: ContractJson.Options),
|
||||||
|
};
|
||||||
|
HttpResponseMessage deleted = await client.SendAsync(deleteRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, store.GetListing(session.ListingId, false).Code);
|
||||||
|
|
||||||
|
await app.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionLeaseServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RegistrationReturnsOpaqueCredentialsButRemainsHiddenUntilPresence()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
|
||||||
|
RegisterSessionResponse response = fixture.Register();
|
||||||
|
|
||||||
|
Assert.Equal(30, response.LeaseRenewAfterSeconds);
|
||||||
|
Assert.Equal(10, response.HostPresenceRefreshAfterSeconds);
|
||||||
|
Assert.Equal(43, response.LeaseToken.Length);
|
||||||
|
Assert.Equal(43, response.HostPresenceCapability.Length);
|
||||||
|
Assert.Empty(fixture.Browse());
|
||||||
|
string json = JsonSerializer.Serialize(response, ContractJson.Options);
|
||||||
|
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("fingerprint", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("store", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExactRegistrationRetryReproducesIdsAndCapabilitiesWithoutRetainingPlaintext()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionRequest request = fixture.Request("same-key");
|
||||||
|
|
||||||
|
RegisterSessionResponse first = fixture.Register(request);
|
||||||
|
RegisterSessionRequest reordered = fixture.Request("same-key");
|
||||||
|
reordered.Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["map"] = "europa",
|
||||||
|
["mode"] = "co-op",
|
||||||
|
};
|
||||||
|
RegisterSessionResponse duplicate = fixture.Register(reordered);
|
||||||
|
RegisterSessionRequest changedRequest = fixture.Request("same-key");
|
||||||
|
changedRequest.DisplayName = "Changed";
|
||||||
|
SessionServiceResult<RegisterSessionResponse> changed = fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
changedRequest);
|
||||||
|
|
||||||
|
Assert.Equal(first.ListingId, duplicate.ListingId);
|
||||||
|
Assert.Equal(first.LeaseId, duplicate.LeaseId);
|
||||||
|
Assert.Equal(first.LeaseToken, duplicate.LeaseToken);
|
||||||
|
Assert.Equal(first.HostPresenceHandle, duplicate.HostPresenceHandle);
|
||||||
|
Assert.Equal(first.HostPresenceCapability, duplicate.HostPresenceCapability);
|
||||||
|
Assert.Equal(RendezvousErrorCode.Conflict, changed.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReRegistrationAfterIdempotencyExpiryRotatesIdsAndCapabilities()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new()
|
||||||
|
{
|
||||||
|
LeaseLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
JoinAttemptLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
IdempotencyLifetime = TimeSpan.FromSeconds(6),
|
||||||
|
};
|
||||||
|
using SessionLeaseFixture fixture = new(options);
|
||||||
|
RegisterSessionRequest request = fixture.Request("reused-after-expiry");
|
||||||
|
RegisterSessionResponse first = fixture.Register(request);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(options.IdempotencyLifetime);
|
||||||
|
RegisterSessionResponse second = fixture.Register(request);
|
||||||
|
|
||||||
|
Assert.NotEqual(first.ListingId, second.ListingId);
|
||||||
|
Assert.NotEqual(first.LeaseId, second.LeaseId);
|
||||||
|
Assert.NotEqual(first.LeaseToken, second.LeaseToken);
|
||||||
|
Assert.NotEqual(first.HostPresenceCapability, second.HostPresenceCapability);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresenceTransitionsAwaitingToListedToStaleAndBackWithoutChangingIdentity()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
|
||||||
|
Assert.Empty(fixture.Browse());
|
||||||
|
Assert.True(fixture.BindPresence(registration).Succeeded);
|
||||||
|
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(fixture.StoreOptions.PresenceLifetime);
|
||||||
|
Assert.Empty(fixture.Browse());
|
||||||
|
Assert.True(fixture.BindPresence(registration).Succeeded);
|
||||||
|
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenewUpdateAndDeleteMaintainCanonicalIdentityAndAdvisoryCapacity()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
SessionServiceResult<RenewLeaseResponse> renewed = fixture.Service.Renew(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken });
|
||||||
|
SessionServiceResult<bool> updated = fixture.Service.Update(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
LeaseToken = registration.LeaseToken,
|
||||||
|
BuildVersion = "1.4.3",
|
||||||
|
DisplayName = "Europa Updated",
|
||||||
|
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mode"] = "co-op",
|
||||||
|
["map"] = "europa",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!;
|
||||||
|
|
||||||
|
Assert.True(renewed.Succeeded);
|
||||||
|
Assert.Equal(fixture.Clock.UtcNow.Add(fixture.StoreOptions.LeaseLifetime), renewed.Value!.ExpiresAt);
|
||||||
|
Assert.True(updated.Succeeded);
|
||||||
|
Assert.Equal(registration.ListingId, stored.Definition.ListingId);
|
||||||
|
Assert.Equal(fixture.Scope, stored.Definition.Scope);
|
||||||
|
Assert.Equal(8, stored.Definition.CurrentPlayers);
|
||||||
|
Assert.Equal(8, stored.Definition.MaximumPlayers);
|
||||||
|
Assert.True(fixture.Service.Delete(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken }).Succeeded);
|
||||||
|
Assert.True(fixture.Service.Delete(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken }).Succeeded);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnotherPublisherCannotRenewUpdateOrDeleteListing()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
DedicatedPublisherPrincipal other = fixture.Publisher("publisher-2");
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew(
|
||||||
|
other,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken }).Error);
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Update(
|
||||||
|
other,
|
||||||
|
registration.ListingId,
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
LeaseToken = registration.LeaseToken,
|
||||||
|
BuildVersion = "1.4.3",
|
||||||
|
DisplayName = "Hijacked",
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 2 },
|
||||||
|
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
|
||||||
|
}).Error);
|
||||||
|
Assert.True(fixture.Service.Delete(
|
||||||
|
other,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken }).Succeeded);
|
||||||
|
Assert.True(fixture.Store.GetListing(registration.ListingId, false).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentRenewDeleteCannotResurrectListing()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
using ManualResetEventSlim start = new(false);
|
||||||
|
Task<SessionServiceResult<RenewLeaseResponse>> renew = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Service.Renew(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken });
|
||||||
|
});
|
||||||
|
Task<SessionServiceResult<bool>> delete = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Service.Delete(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
start.Set();
|
||||||
|
await Task.WhenAll(renew, delete);
|
||||||
|
SessionServiceResult<RenewLeaseResponse> renewResult = await renew;
|
||||||
|
SessionServiceResult<bool> deleteResult = await delete;
|
||||||
|
|
||||||
|
Assert.True(deleteResult.Succeeded);
|
||||||
|
Assert.Contains(renewResult.Error, new[]
|
||||||
|
{
|
||||||
|
RendezvousErrorCode.None,
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
RendezvousErrorCode.Conflict,
|
||||||
|
});
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbandonedRegistrationExpiresAndFreesBoundedCapacity()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new()
|
||||||
|
{
|
||||||
|
MaxListings = 1,
|
||||||
|
LeaseLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
};
|
||||||
|
using SessionLeaseFixture fixture = new(options);
|
||||||
|
fixture.Register(fixture.Request("first"));
|
||||||
|
Assert.Equal(RendezvousErrorCode.CapacityExceeded, fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
fixture.Request("second")).Error);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(options.LeaseLifetime);
|
||||||
|
|
||||||
|
Assert.True(fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
fixture.Request("second")).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeaseExpiryRemovesMutationAndPresencePaths()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
fixture.BindPresence(registration);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(fixture.StoreOptions.LeaseLifetime);
|
||||||
|
|
||||||
|
Assert.Empty(fixture.Browse());
|
||||||
|
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken }).Error);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, fixture.BindPresence(registration).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LossOfAtomicStateFailsLeaseMutationClosed()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionResponse registration = fixture.Register();
|
||||||
|
fixture.Store.MarkUnavailable();
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, fixture.Service.Renew(
|
||||||
|
fixture.Principal,
|
||||||
|
registration.ListingId,
|
||||||
|
new() { LeaseToken = registration.LeaseToken }).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidPolicyBoundInputsReturnStableTypedErrors()
|
||||||
|
{
|
||||||
|
using SessionLeaseFixture fixture = new();
|
||||||
|
RegisterSessionRequest capacity = fixture.Request("bad-capacity");
|
||||||
|
capacity.Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 1 };
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
capacity).Error);
|
||||||
|
RegisterSessionRequest protocol = fixture.Request("bad-protocol");
|
||||||
|
protocol.ProtocolVersion = 8;
|
||||||
|
Assert.Equal(RendezvousErrorCode.IncompatibleProtocol, fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
protocol).Error);
|
||||||
|
RegisterSessionRequest region = fixture.Request("bad-region");
|
||||||
|
region.RegionId = new("us-east");
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
region).Error);
|
||||||
|
RegisterSessionRequest visibility = fixture.Request("bad-visibility");
|
||||||
|
visibility.Visibility = (ListingVisibility)99;
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
visibility).Error);
|
||||||
|
RegisterSessionRequest metadata = fixture.Request("bad-metadata");
|
||||||
|
metadata.Metadata = new Dictionary<string, string> { ["unknown"] = "value" };
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
metadata).Error);
|
||||||
|
RegisterSessionRequest build = fixture.Request("bad-build");
|
||||||
|
build.BuildVersion = " ";
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
|
||||||
|
fixture.Principal,
|
||||||
|
build).Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||||
|
|
||||||
|
internal sealed class SessionLeaseFixture : IDisposable
|
||||||
|
{
|
||||||
|
private int _sequence;
|
||||||
|
|
||||||
|
public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null)
|
||||||
|
{
|
||||||
|
StoreOptions = storeOptions ?? new EphemeralStoreOptions();
|
||||||
|
Clock = new();
|
||||||
|
Store = new(StoreOptions, Clock, Clock);
|
||||||
|
Capabilities = new();
|
||||||
|
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
|
||||||
|
Service = new(
|
||||||
|
new PublisherAuthorizationService(policies),
|
||||||
|
Store,
|
||||||
|
Capabilities,
|
||||||
|
SessionLeaseTiming.From(StoreOptions),
|
||||||
|
Clock);
|
||||||
|
Principal = Publisher("publisher-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public EphemeralStoreOptions StoreOptions { get; }
|
||||||
|
public ManualRendezvousClock Clock { get; }
|
||||||
|
public InMemoryEphemeralRendezvousStore Store { get; }
|
||||||
|
public EphemeralCapabilityIssuer Capabilities { get; }
|
||||||
|
public SessionLeaseService Service { get; }
|
||||||
|
public DedicatedPublisherPrincipal Principal { get; }
|
||||||
|
public TenantScope Scope { get; } = new(new("space-game"), new("production"));
|
||||||
|
|
||||||
|
public DedicatedPublisherPrincipal Publisher(string subject) => new(
|
||||||
|
subject,
|
||||||
|
Clock.UtcNow.AddMinutes(10),
|
||||||
|
Scope.GameId,
|
||||||
|
Scope.EnvironmentId,
|
||||||
|
new HashSet<RegionId> { new("eu-central") });
|
||||||
|
|
||||||
|
public RegisterSessionRequest Request(string? idempotencyKey = null) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = idempotencyKey ?? $"register-{Interlocked.Increment(ref _sequence)}",
|
||||||
|
GameId = Scope.GameId,
|
||||||
|
EnvironmentId = Scope.EnvironmentId,
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.4.2",
|
||||||
|
DisplayName = "Europa Relay",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mode"] = "co-op",
|
||||||
|
["map"] = "europa",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public RegisterSessionResponse Register(
|
||||||
|
RegisterSessionRequest? request = null,
|
||||||
|
DedicatedPublisherPrincipal? principal = null)
|
||||||
|
{
|
||||||
|
SessionServiceResult<RegisterSessionResponse> result = Service.Register(
|
||||||
|
principal ?? Principal,
|
||||||
|
request ?? Request());
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
return result.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoreResult<StoredListing> BindPresence(RegisterSessionResponse registration)
|
||||||
|
{
|
||||||
|
Assert.True(Capabilities.TryFingerprint(
|
||||||
|
registration.HostPresenceCapability,
|
||||||
|
out SecretFingerprint fingerprint));
|
||||||
|
return Store.BindHostPresence(new(
|
||||||
|
registration.HostPresenceHandle,
|
||||||
|
fingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.50", 40_000),
|
||||||
|
new ObservedEndpoint(AddressFamilyKind.Ipv4, "192.168.1.50", 40_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<StoredListing> Browse() => Store.BrowseVisibleListings(new(
|
||||||
|
Scope,
|
||||||
|
7,
|
||||||
|
new RegionId("eu-central"))).Value!;
|
||||||
|
|
||||||
|
public void Dispose() => Capabilities.Dispose();
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ namespace FinalFactory.Rendezvous.Tests.State;
|
|||||||
|
|
||||||
internal sealed class ManualRendezvousClock : IWallClock, IMonotonicClock
|
internal sealed class ManualRendezvousClock : IWallClock, IMonotonicClock
|
||||||
{
|
{
|
||||||
public DateTimeOffset UtcNow { get; private set; } = new(2026, 7, 16, 0, 0, 0, TimeSpan.Zero);
|
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 TimeSpan Elapsed { get; private set; }
|
||||||
|
|
||||||
public void Advance(TimeSpan duration)
|
public void Advance(TimeSpan duration)
|
||||||
@@ -58,6 +61,7 @@ internal sealed class EphemeralStateFixture
|
|||||||
LeaseFingerprint = Fingerprint($"lease-{sequence}"),
|
LeaseFingerprint = Fingerprint($"lease-{sequence}"),
|
||||||
HostPresenceHandle = NewHandle(),
|
HostPresenceHandle = NewHandle(),
|
||||||
HostPresenceFingerprint = Fingerprint($"presence-{sequence}"),
|
HostPresenceFingerprint = Fingerprint($"presence-{sequence}"),
|
||||||
|
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +95,8 @@ internal sealed class EphemeralStateFixture
|
|||||||
ProtocolVersion = listing.Definition.ProtocolVersion,
|
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||||
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
|
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
|
||||||
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
|
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
|
||||||
|
ConnectionTicketFingerprint = Fingerprint($"ticket-{sequence}"),
|
||||||
|
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ namespace FinalFactory.Rendezvous.Tests.State;
|
|||||||
|
|
||||||
public sealed class InMemoryEphemeralRendezvousStoreTests
|
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]
|
[Fact]
|
||||||
public void DuplicateRegistrationIsIdempotentButChangedRequestConflicts()
|
public void DuplicateRegistrationIsIdempotentButChangedRequestConflicts()
|
||||||
{
|
{
|
||||||
@@ -49,6 +59,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
|
|||||||
listing.Definition.ListingId,
|
listing.Definition.ListingId,
|
||||||
listing.Definition.LeaseId,
|
listing.Definition.LeaseId,
|
||||||
listing.Definition.LeaseFingerprint,
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
listing.Version));
|
listing.Version));
|
||||||
Assert.Equal(new DateTimeOffset(2026, 7, 16, 0, 1, 1, TimeSpan.Zero), renewed.Value!.LeaseExpiresAt);
|
Assert.Equal(new DateTimeOffset(2026, 7, 16, 0, 1, 1, TimeSpan.Zero), renewed.Value!.LeaseExpiresAt);
|
||||||
fixture.Clock.MoveWall(TimeSpan.FromDays(-60));
|
fixture.Clock.MoveWall(TimeSpan.FromDays(-60));
|
||||||
@@ -72,6 +83,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
|
|||||||
listing.Definition.ListingId,
|
listing.Definition.ListingId,
|
||||||
listing.Definition.LeaseId,
|
listing.Definition.LeaseId,
|
||||||
listing.Definition.LeaseFingerprint,
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
listing.Version));
|
listing.Version));
|
||||||
});
|
});
|
||||||
Task<StoreResult<bool>> delete = Task.Run(() =>
|
Task<StoreResult<bool>> delete = Task.Run(() =>
|
||||||
@@ -80,7 +92,8 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
|
|||||||
return fixture.Store.DeleteListing(new(
|
return fixture.Store.DeleteListing(new(
|
||||||
command.Listing.ListingId,
|
command.Listing.ListingId,
|
||||||
command.Listing.LeaseId,
|
command.Listing.LeaseId,
|
||||||
command.Listing.LeaseFingerprint));
|
command.Listing.LeaseFingerprint,
|
||||||
|
command.Listing.OwnerSubject));
|
||||||
});
|
});
|
||||||
|
|
||||||
start.Set();
|
start.Set();
|
||||||
@@ -102,6 +115,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
|
|||||||
listing.Definition.ListingId,
|
listing.Definition.ListingId,
|
||||||
listing.Definition.LeaseId,
|
listing.Definition.LeaseId,
|
||||||
listing.Definition.LeaseFingerprint,
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
listing.Version);
|
listing.Version);
|
||||||
|
|
||||||
StoreResult<StoredListing> first = fixture.Store.RenewLease(command);
|
StoreResult<StoredListing> first = fixture.Store.RenewLease(command);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
public sealed class StoreResultMappingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EveryStoreResultHasOneSharedContractErrorMapping()
|
||||||
|
{
|
||||||
|
Dictionary<StoreResultCode, RendezvousErrorCode> expected = new()
|
||||||
|
{
|
||||||
|
[StoreResultCode.Success] = RendezvousErrorCode.None,
|
||||||
|
[StoreResultCode.NotFound] = RendezvousErrorCode.NotFound,
|
||||||
|
[StoreResultCode.Expired] = RendezvousErrorCode.Expired,
|
||||||
|
[StoreResultCode.Revoked] = RendezvousErrorCode.Forbidden,
|
||||||
|
[StoreResultCode.Conflict] = RendezvousErrorCode.Conflict,
|
||||||
|
[StoreResultCode.CapacityExceeded] = RendezvousErrorCode.CapacityExceeded,
|
||||||
|
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
|
||||||
|
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count);
|
||||||
|
foreach (KeyValuePair<StoreResultCode, RendezvousErrorCode> item in expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(item.Value, item.Key.ToContractError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult
|
||||||
|
ENUM Accepted=1
|
||||||
|
ENUM NotFound=2
|
||||||
|
ENUM Expired=3
|
||||||
|
ENUM Rejected=4
|
||||||
|
ENUM AlreadyConsumed=5
|
||||||
|
ENUM Revoked=6
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
|
||||||
|
CTOR (System.Int32 maximumAuthorizedTickets)
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult Consume(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
|
||||||
|
METHOD System.Void Dispose()
|
||||||
|
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
|
||||||
|
METHOD System.String ToString()
|
||||||
|
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
|
||||||
|
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousSessionBrowserClient
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason
|
||||||
|
ENUM Cancelled=1
|
||||||
|
ENUM Disposed=2
|
||||||
|
ENUM LeaseLost=3
|
||||||
|
ENUM Failed=4
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.PublishedSession
|
||||||
|
PROP System.DateTimeOffset ExpiresAt {get;}
|
||||||
|
PROP System.String HostPresenceCapability {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;}
|
||||||
|
PROP System.Int32 HostPresenceRefreshAfterSeconds {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;}
|
||||||
|
PROP System.Int32 LeaseRenewAfterSeconds {get;}
|
||||||
|
PROP System.String LeaseToken {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;}
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
|
||||||
|
CTOR ()
|
||||||
|
PROP System.TimeSpan InitialRetryDelay {get;set;}
|
||||||
|
PROP System.Double JitterRatio {get;set;}
|
||||||
|
PROP System.TimeSpan MaximumRetryDelay {get;set;}
|
||||||
|
PROP System.Int32 MaximumSafeRetries {get;set;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable<System.Int32> retryAfterSeconds)
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Success(T value)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||||
|
PROP System.Boolean IsSuccess {get;}
|
||||||
|
PROP System.String Message {get;}
|
||||||
|
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;}
|
||||||
|
PROP T Value {get;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient
|
||||||
|
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousSessionBrowserClient
|
||||||
|
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
|
||||||
|
EVENT System.EventHandler LeaseLost
|
||||||
|
METHOD System.Threading.Tasks.ValueTask DisposeAsync()
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.LeaseMaintenanceResult> RunAsync(System.Threading.CancellationToken cancellationToken)
|
||||||
@@ -18,6 +18,7 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest
|
|||||||
PROP System.Int32 ContractVersion {get;set;}
|
PROP System.Int32 ContractVersion {get;set;}
|
||||||
PROP System.String Cursor {get;set;}
|
PROP System.String Cursor {get;set;}
|
||||||
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
|
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
|
||||||
|
PROP System.Boolean ExcludeFull {get;set;}
|
||||||
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
|
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
|
||||||
PROP System.Int32 PageSize {get;set;}
|
PROP System.Int32 PageSize {get;set;}
|
||||||
PROP System.UInt32 ProtocolVersion {get;set;}
|
PROP System.UInt32 ProtocolVersion {get;set;}
|
||||||
@@ -217,7 +218,9 @@ TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionResponse
|
|||||||
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
||||||
PROP System.String HostPresenceCapability {get;set;}
|
PROP System.String HostPresenceCapability {get;set;}
|
||||||
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;set;}
|
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;set;}
|
||||||
|
PROP System.Int32 HostPresenceRefreshAfterSeconds {get;set;}
|
||||||
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;}
|
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;}
|
||||||
|
PROP System.Int32 LeaseRenewAfterSeconds {get;set;}
|
||||||
PROP System.String LeaseToken {get;set;}
|
PROP System.String LeaseToken {get;set;}
|
||||||
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
|
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
|
||||||
TYPE FinalFactory.Rendezvous.Contracts.RendezvousErrorCode
|
TYPE FinalFactory.Rendezvous.Contracts.RendezvousErrorCode
|
||||||
@@ -250,6 +253,7 @@ TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseResponse
|
|||||||
CTOR ()
|
CTOR ()
|
||||||
PROP System.Int32 ContractVersion {get;set;}
|
PROP System.Int32 ContractVersion {get;set;}
|
||||||
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
||||||
|
PROP System.Int32 RenewAfterSeconds {get;set;}
|
||||||
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
|
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
|
||||||
CTOR ()
|
CTOR ()
|
||||||
PROP System.Int32 ContractVersion {get;set;}
|
PROP System.Int32 ContractVersion {get;set;}
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","leaseId":"11112233-4455-6677-8899-aabbccddeeff","leaseToken":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","hostPresenceHandle":"22222233-4455-6677-8899-aabbccddeeff","hostPresenceCapability":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","expiresAt":"2026-07-16T12:01:00+00:00","leaseRenewAfterSeconds":30,"hostPresenceRefreshAfterSeconds":10}
|
||||||
Reference in New Issue
Block a user