Compare commits

..

1 Commits

Author SHA1 Message Date
KyuubiYoru 49564c7e7e feat: add presence-gated session leases (#7)
quality-gate / quality (push) Successful in 55s
Closes #7
2026-07-16 05:58:47 +02:00
25 changed files with 2069 additions and 46 deletions
+243 -10
View File
@@ -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": [
@@ -211,8 +276,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 +346,12 @@
} }
} }
} }
},
"security": [
{
"PublisherBearer": [ ]
} }
]
} }
}, },
"/v1/sessions/{listingId}": { "/v1/sessions/{listingId}": {
@@ -254,8 +384,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 +434,12 @@
} }
} }
} }
},
"security": [
{
"PublisherBearer": [ ]
} }
]
}, },
"delete": { "delete": {
"tags": [ "tags": [
@@ -295,8 +470,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 +510,12 @@
} }
} }
} }
},
"security": [
{
"PublisherBearer": [ ]
} }
]
}, },
"get": { "get": {
"tags": [ "tags": [
@@ -875,7 +1085,9 @@
"leaseToken", "leaseToken",
"hostPresenceHandle", "hostPresenceHandle",
"hostPresenceCapability", "hostPresenceCapability",
"expiresAt" "expiresAt",
"leaseRenewAfterSeconds",
"hostPresenceRefreshAfterSeconds"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -901,6 +1113,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 +1162,8 @@
"RenewLeaseResponse": { "RenewLeaseResponse": {
"required": [ "required": [
"contractVersion", "contractVersion",
"expiresAt" "expiresAt",
"renewAfterSeconds"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -953,6 +1174,10 @@
"expiresAt": { "expiresAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
},
"renewAfterSeconds": {
"type": "integer",
"format": "int32"
} }
} }
}, },
@@ -1115,6 +1340,14 @@
} }
} }
} }
},
"securitySchemes": {
"PublisherBearer": {
"type": "http",
"description": "Tenant-scoped publisher credential issued during game provisioning.",
"scheme": "bearer",
"bearerFormat": "rv1 publisher credential"
}
} }
}, },
"tags": [ "tags": [
@@ -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.
+1
View File
@@ -7,6 +7,7 @@ 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)
- [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)
@@ -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
@@ -1,4 +1,7 @@
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
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,22 +17,41 @@ 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>()
@@ -61,20 +83,115 @@ internal static class ContractEndpoints
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,
@@ -109,4 +226,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;
}
}
+59 -2
View File
@@ -2,6 +2,7 @@ using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
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 +14,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 +34,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 +114,11 @@ 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(new ProvisioningReadiness(true)); builder.Services.AddSingleton(new ProvisioningReadiness(true));
} }
@@ -84,6 +140,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(
@@ -0,0 +1,158 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Sessions;
internal interface ISessionCapabilityService
{
string CreateDerivationSalt();
string DeriveCapability(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt);
Guid DeriveGuid(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt);
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
}
internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDisposable
{
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
private bool _disposed;
public string CreateDerivationSalt()
{
ObjectDisposedException.ThrowIf(_disposed, this);
byte[] salt = RandomNumberGenerator.GetBytes(32);
try
{
return Encode(salt);
}
finally
{
CryptographicOperations.ZeroMemory(salt);
}
}
public string DeriveCapability(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt)
{
byte[] digest = Derive(
purpose,
ownerSubject,
idempotencyKey,
requestFingerprint,
derivationSalt);
try
{
return Encode(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public Guid DeriveGuid(
string purpose,
string ownerSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt)
{
byte[] digest = Derive(
purpose,
ownerSubject,
idempotencyKey,
requestFingerprint,
derivationSalt);
try
{
Span<byte> guidBytes = digest.AsSpan(0, 16);
guidBytes[7] = (byte)((guidBytes[7] & 0x0f) | 0x80);
guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80);
return new Guid(guidBytes);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
{
fingerprint = default;
if (_disposed
|| capability is null
|| capability.Length != 43
|| capability.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
return false;
}
byte[] digest = Derive("fingerprint", capability);
try
{
fingerprint = new SecretFingerprint(Encode(digest));
return true;
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
CryptographicOperations.ZeroMemory(_key);
}
public override string ToString() => "[EphemeralCapabilityIssuer: key and capabilities redacted]";
private byte[] Derive(params string[] segments)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, _key);
Span<byte> length = stackalloc byte[sizeof(int)];
foreach (string segment in segments)
{
ArgumentException.ThrowIfNullOrEmpty(segment);
byte[] encoded = Encoding.UTF8.GetBytes(segment);
BinaryPrimitives.WriteInt32BigEndian(length, encoded.Length);
hmac.AppendData(length);
hmac.AppendData(encoded);
CryptographicOperations.ZeroMemory(encoded);
}
return hmac.GetHashAndReset();
}
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
@@ -0,0 +1,452 @@
using System.Security.Cryptography;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Sessions;
internal sealed record SessionLeaseTiming(
int LeaseRenewAfterSeconds,
int HostPresenceRefreshAfterSeconds)
{
public static SessionLeaseTiming From(EphemeralStoreOptions options) => new(
Math.Max(1, (int)(options.LeaseLifetime.TotalSeconds / 2)),
Math.Max(1, (int)(options.PresenceLifetime.TotalSeconds / 2)));
}
internal sealed record SessionServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed class SessionLeaseService(
PublisherAuthorizationService authorization,
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
SessionLeaseTiming timing,
IWallClock clock)
{
public SessionServiceResult<RegisterSessionResponse> Register(
AuthenticatedPrincipal principal,
RegisterSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = ValidateRegistration(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
PublisherAuthorizationResult authorized = authorization.Authorize(
principal,
request.GameId,
request.EnvironmentId,
request.RegionId,
request.ProtocolVersion,
request.Visibility,
request.Metadata,
clock.UtcNow);
if (!authorized.IsAllowed || authorized.Context is null)
{
return new(MapAuthorization(authorized.Error));
}
AuthorizedPublisherContext context = authorized.Context;
string requestFingerprint = ComputeRegistrationFingerprint(request);
string derivationSalt = capabilities.CreateDerivationSalt();
string leaseToken = capabilities.DeriveCapability(
"lease-token",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt);
string presenceCapability = capabilities.DeriveCapability(
"host-presence",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt);
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint)
|| !capabilities.TryFingerprint(presenceCapability, out SecretFingerprint presenceFingerprint))
{
throw new InvalidOperationException("Derived session capabilities could not be fingerprinted.");
}
SessionListingId listingId = new(capabilities.DeriveGuid(
"listing-id",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
LeaseId leaseId = new(capabilities.DeriveGuid(
"lease-id",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
MediationHandle presenceHandle = new(capabilities.DeriveGuid(
"presence-handle",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
int ownerLimit = context.TrustMode == PublisherTrustMode.AnonymousUnlisted
? context.Policy.MaxAnonymousListingsPerAddress
: context.Policy.MaxListingsPerPrincipal;
if (ownerLimit <= 0)
{
return new(RendezvousErrorCode.CapacityExceeded);
}
StoreResult<StoredListing> created = store.CreateListing(new(
request.IdempotencyKey,
requestFingerprint,
new ListingDefinition
{
ListingId = listingId,
LeaseId = leaseId,
Scope = new(context.GameId, context.EnvironmentId),
OwnerSubject = context.Subject,
RegionId = context.RegionId,
ProtocolVersion = context.ProtocolVersion,
BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName,
Visibility = context.Visibility,
TrustMode = context.TrustMode,
CurrentPlayers = request.Capacity.CurrentPlayers,
MaximumPlayers = request.Capacity.MaximumPlayers,
Metadata = request.Metadata,
LeaseFingerprint = leaseFingerprint,
HostPresenceHandle = presenceHandle,
HostPresenceFingerprint = presenceFingerprint,
CapabilityDerivationSalt = derivationSalt,
},
ownerLimit), cancellationToken);
if (!created.Succeeded || created.Value is null)
{
return new(MapStore(created.Code));
}
ListingDefinition persisted = created.Value.Definition;
leaseToken = capabilities.DeriveCapability(
"lease-token",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
persisted.CapabilityDerivationSalt);
presenceCapability = capabilities.DeriveCapability(
"host-presence",
context.Subject,
request.IdempotencyKey,
requestFingerprint,
persisted.CapabilityDerivationSalt);
return new(RendezvousErrorCode.None, new RegisterSessionResponse
{
ListingId = persisted.ListingId,
LeaseId = persisted.LeaseId,
LeaseToken = leaseToken,
HostPresenceHandle = persisted.HostPresenceHandle,
HostPresenceCapability = presenceCapability,
ExpiresAt = created.Value.LeaseExpiresAt,
LeaseRenewAfterSeconds = timing.LeaseRenewAfterSeconds,
HostPresenceRefreshAfterSeconds = timing.HostPresenceRefreshAfterSeconds,
});
}
public SessionServiceResult<RenewLeaseResponse> Renew(
AuthenticatedPrincipal principal,
SessionListingId listingId,
RenewLeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
RendezvousErrorCode lookup = GetAuthorizedListing(
principal,
listingId,
request.LeaseToken,
cancellationToken,
out StoredListing? listing);
if (lookup != RendezvousErrorCode.None)
{
return new(lookup);
}
StoredListing ownedListing = listing!;
PublisherAuthorizationResult authorized = AuthorizeExisting(
principal,
ownedListing,
ownedListing.Definition.Metadata);
if (!authorized.IsAllowed)
{
return new(MapAuthorization(authorized.Error));
}
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
StoreResult<StoredListing> renewed = store.RenewLease(new(
listingId,
ownedListing.Definition.LeaseId,
fingerprint,
ownedListing.Definition.OwnerSubject,
ownedListing.Version), cancellationToken);
return renewed.Succeeded && renewed.Value is not null
? new(RendezvousErrorCode.None, new RenewLeaseResponse
{
ExpiresAt = renewed.Value.LeaseExpiresAt,
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
})
: new(MapStore(renewed.Code));
}
public SessionServiceResult<bool> Update(
AuthenticatedPrincipal principal,
SessionListingId listingId,
UpdateSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = ValidateUpdate(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
RendezvousErrorCode lookup = GetAuthorizedListing(
principal,
listingId,
request.LeaseToken,
cancellationToken,
out StoredListing? listing);
if (lookup != RendezvousErrorCode.None)
{
return new(lookup);
}
StoredListing ownedListing = listing!;
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
if (!authorized.IsAllowed)
{
return new(MapAuthorization(authorized.Error));
}
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
StoreResult<StoredListing> updated = store.UpdateListing(new(
listingId,
ownedListing.Definition.LeaseId,
fingerprint,
ownedListing.Definition.OwnerSubject,
request.BuildVersion,
request.DisplayName,
request.Capacity.CurrentPlayers,
request.Capacity.MaximumPlayers,
request.Metadata), cancellationToken);
return updated.Succeeded
? new(RendezvousErrorCode.None, true)
: new(MapStore(updated.Code));
}
public SessionServiceResult<bool> Delete(
AuthenticatedPrincipal principal,
SessionListingId listingId,
DeleteSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
if (principal is not IPublisherPrincipal publisher
|| !capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint))
{
return new(RendezvousErrorCode.Forbidden);
}
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return found.Code == StoreResultCode.ServiceUnavailable
? new(RendezvousErrorCode.ServiceUnavailable)
: new(RendezvousErrorCode.None, true);
}
StoreResult<bool> deleted = store.DeleteListing(new(
listingId,
found.Value.Definition.LeaseId,
fingerprint,
publisher.Subject), cancellationToken);
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
? new(RendezvousErrorCode.None, true)
: new(MapStore(deleted.Code));
}
private RendezvousErrorCode GetAuthorizedListing(
AuthenticatedPrincipal principal,
SessionListingId listingId,
string leaseToken,
CancellationToken cancellationToken,
out StoredListing? listing)
{
listing = null;
if (principal is not IPublisherPrincipal publisher)
{
return RendezvousErrorCode.Forbidden;
}
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint fingerprint))
{
return RendezvousErrorCode.NotFound;
}
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return MapStore(found.Code);
}
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|| found.Value.Definition.LeaseFingerprint != fingerprint)
{
return RendezvousErrorCode.NotFound;
}
listing = found.Value;
return RendezvousErrorCode.None;
}
private PublisherAuthorizationResult AuthorizeExisting(
AuthenticatedPrincipal principal,
StoredListing listing,
IReadOnlyDictionary<string, string> metadata) => authorization.Authorize(
principal,
listing.Definition.Scope.GameId,
listing.Definition.Scope.EnvironmentId,
listing.Definition.RegionId,
listing.Definition.ProtocolVersion,
listing.Definition.Visibility,
metadata,
clock.UtcNow);
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|| string.IsNullOrEmpty(request.GameId.Value)
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|| string.IsNullOrEmpty(request.RegionId.Value)
|| request.ProtocolVersion == 0
|| !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !Enum.IsDefined(request.Visibility)
|| !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static RendezvousErrorCode ValidateUpdate(UpdateSessionRequest request)
{
RendezvousErrorCode lease = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
if (lease != RendezvousErrorCode.None)
{
return lease;
}
return !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static RendezvousErrorCode ValidateLeaseRequest(int contractVersion, string leaseToken)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
? RendezvousErrorCode.None
: RendezvousErrorCode.InvalidRequest;
}
private static RendezvousErrorCode MapAuthorization(PublisherAuthorizationError error) => error switch
{
PublisherAuthorizationError.PrincipalExpired => RendezvousErrorCode.AuthenticationRequired,
PublisherAuthorizationError.ProtocolNotAllowed => RendezvousErrorCode.IncompatibleProtocol,
PublisherAuthorizationError.RegionNotAllowed
or PublisherAuthorizationError.VisibilityNotAllowed
or PublisherAuthorizationError.AnonymousMustBeUnlisted
or PublisherAuthorizationError.MetadataNotAllowed => RendezvousErrorCode.InvalidRequest,
_ => RendezvousErrorCode.Forbidden,
};
private static RendezvousErrorCode MapStore(StoreResultCode code) => code switch
{
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
StoreResultCode.Expired => RendezvousErrorCode.Expired,
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError,
};
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
{
RegisterSessionRequest canonical = new()
{
ContractVersion = request.ContractVersion,
IdempotencyKey = request.IdempotencyKey,
GameId = request.GameId,
EnvironmentId = request.EnvironmentId,
RegionId = request.RegionId,
ProtocolVersion = request.ProtocolVersion,
BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName,
Visibility = request.Visibility,
Capacity = new SessionCapacity
{
CurrentPlayers = request.Capacity.CurrentPlayers,
MaximumPlayers = request.Capacity.MaximumPlayers,
},
Metadata = request.Metadata
.OrderBy(static item => item.Key, StringComparer.Ordinal)
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
};
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
byte[] digest = SHA256.HashData(encoded);
CryptographicOperations.ZeroMemory(encoded);
try
{
return Convert.ToBase64String(digest).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
}
@@ -53,6 +53,12 @@ internal sealed record EphemeralStoreOptions
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 (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 +80,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 +91,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 +167,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 +193,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,
@@ -265,6 +308,7 @@ 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);
@@ -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);
} }
@@ -287,7 +335,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))
@@ -699,7 +747,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));
} }
@@ -753,6 +802,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 +825,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;
@@ -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,
}
@@ -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) },
@@ -64,5 +64,26 @@ 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 _));
}
} }
} }
@@ -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,145 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
using FinalFactory.Rendezvous.Tests.State;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace FinalFactory.Rendezvous.Tests.Sessions;
public sealed class SessionHttpEndpointTests
{
[Fact]
public async Task AuthenticatedHttpLifecycleReturnsStableContractsAndStatuses()
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(),
ProvisioningTestData.CreateSecrets("secret-1"),
clock.UtcNow);
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
string publisherCredential = provisioning.Credentials.Issue(principal, clock.UtcNow);
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions));
builder.Services.Configure<RouteHandlerOptions>(static options =>
options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
builder.Services.AddSingleton<IWallClock>(clock);
builder.Services.AddSingleton(capabilities);
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
await using WebApplication app = builder.Build();
app.UseExceptionHandler();
app.MapRendezvousContractEndpoints();
await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>();
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
using HttpClient client = new() { BaseAddress = new Uri(address) };
RegisterSessionRequest registration = new()
{
IdempotencyKey = "http-register-1",
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.4.2",
DisplayName = "HTTP host",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
},
};
HttpResponseMessage unauthenticated = await client.PostAsJsonAsync(
"/v1/sessions",
registration,
ContractJson.Options);
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
Assert.Equal("Bearer", Assert.Single(unauthenticated.Headers.WwwAuthenticate).Scheme);
ApiError? authenticationError = await unauthenticated.Content.ReadFromJsonAsync<ApiError>(
ContractJson.Options);
Assert.Equal(RendezvousErrorCode.AuthenticationRequired, authenticationError!.Code);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
publisherCredential);
string invalidJson = JsonSerializer.Serialize(registration, ContractJson.Options)
.Replace("\"public\"", "\"futureVisibility\"", StringComparison.Ordinal);
HttpResponseMessage invalid = await client.PostAsync(
"/v1/sessions",
new StringContent(invalidJson, Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
ApiError? invalidError = await invalid.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options);
Assert.Equal(RendezvousErrorCode.InvalidRequest, invalidError!.Code);
HttpResponseMessage created = await client.PostAsJsonAsync(
"/v1/sessions",
registration,
ContractJson.Options);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
RegisterSessionResponse? session = await created.Content.ReadFromJsonAsync<RegisterSessionResponse>(
ContractJson.Options);
Assert.NotNull(session);
Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString);
HttpResponseMessage renewed = await client.PostAsJsonAsync(
$"/v1/sessions/{session.ListingId}/renew",
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
ContractJson.Options);
Assert.Equal(HttpStatusCode.OK, renewed.StatusCode);
Assert.NotNull(await renewed.Content.ReadFromJsonAsync<RenewLeaseResponse>(ContractJson.Options));
HttpResponseMessage updated = await client.PutAsJsonAsync(
$"/v1/sessions/{session.ListingId}",
new UpdateSessionRequest
{
LeaseToken = session.LeaseToken,
BuildVersion = "1.4.3",
DisplayName = "HTTP host updated",
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
},
ContractJson.Options);
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
using HttpRequestMessage deleteRequest = new(
HttpMethod.Delete,
$"/v1/sessions/{session.ListingId}")
{
Content = JsonContent.Create(
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
options: ContractJson.Options),
};
HttpResponseMessage deleted = await client.SendAsync(deleteRequest);
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
Assert.Equal(StoreResultCode.NotFound, store.GetListing(session.ListingId, false).Code);
await app.StopAsync();
}
}
@@ -0,0 +1,294 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.Sessions;
public sealed class SessionLeaseServiceTests
{
[Fact]
public void RegistrationReturnsOpaqueCredentialsButRemainsHiddenUntilPresence()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse response = fixture.Register();
Assert.Equal(30, response.LeaseRenewAfterSeconds);
Assert.Equal(10, response.HostPresenceRefreshAfterSeconds);
Assert.Equal(43, response.LeaseToken.Length);
Assert.Equal(43, response.HostPresenceCapability.Length);
Assert.Empty(fixture.Browse());
string json = JsonSerializer.Serialize(response, ContractJson.Options);
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("fingerprint", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("store", json, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ExactRegistrationRetryReproducesIdsAndCapabilitiesWithoutRetainingPlaintext()
{
using SessionLeaseFixture fixture = new();
RegisterSessionRequest request = fixture.Request("same-key");
RegisterSessionResponse first = fixture.Register(request);
RegisterSessionRequest reordered = fixture.Request("same-key");
reordered.Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["map"] = "europa",
["mode"] = "co-op",
};
RegisterSessionResponse duplicate = fixture.Register(reordered);
RegisterSessionRequest changedRequest = fixture.Request("same-key");
changedRequest.DisplayName = "Changed";
SessionServiceResult<RegisterSessionResponse> changed = fixture.Service.Register(
fixture.Principal,
changedRequest);
Assert.Equal(first.ListingId, duplicate.ListingId);
Assert.Equal(first.LeaseId, duplicate.LeaseId);
Assert.Equal(first.LeaseToken, duplicate.LeaseToken);
Assert.Equal(first.HostPresenceHandle, duplicate.HostPresenceHandle);
Assert.Equal(first.HostPresenceCapability, duplicate.HostPresenceCapability);
Assert.Equal(RendezvousErrorCode.Conflict, changed.Error);
}
[Fact]
public void ReRegistrationAfterIdempotencyExpiryRotatesIdsAndCapabilities()
{
EphemeralStoreOptions options = new()
{
LeaseLifetime = TimeSpan.FromSeconds(5),
JoinAttemptLifetime = TimeSpan.FromSeconds(5),
IdempotencyLifetime = TimeSpan.FromSeconds(6),
};
using SessionLeaseFixture fixture = new(options);
RegisterSessionRequest request = fixture.Request("reused-after-expiry");
RegisterSessionResponse first = fixture.Register(request);
fixture.Clock.Advance(options.IdempotencyLifetime);
RegisterSessionResponse second = fixture.Register(request);
Assert.NotEqual(first.ListingId, second.ListingId);
Assert.NotEqual(first.LeaseId, second.LeaseId);
Assert.NotEqual(first.LeaseToken, second.LeaseToken);
Assert.NotEqual(first.HostPresenceCapability, second.HostPresenceCapability);
}
[Fact]
public void PresenceTransitionsAwaitingToListedToStaleAndBackWithoutChangingIdentity()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
Assert.Empty(fixture.Browse());
Assert.True(fixture.BindPresence(registration).Succeeded);
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
fixture.Clock.Advance(fixture.StoreOptions.PresenceLifetime);
Assert.Empty(fixture.Browse());
Assert.True(fixture.BindPresence(registration).Succeeded);
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
}
[Fact]
public void RenewUpdateAndDeleteMaintainCanonicalIdentityAndAdvisoryCapacity()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
SessionServiceResult<RenewLeaseResponse> renewed = fixture.Service.Renew(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken });
SessionServiceResult<bool> updated = fixture.Service.Update(
fixture.Principal,
registration.ListingId,
new()
{
LeaseToken = registration.LeaseToken,
BuildVersion = "1.4.3",
DisplayName = "Europa Updated",
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
},
});
StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!;
Assert.True(renewed.Succeeded);
Assert.Equal(fixture.Clock.UtcNow.Add(fixture.StoreOptions.LeaseLifetime), renewed.Value!.ExpiresAt);
Assert.True(updated.Succeeded);
Assert.Equal(registration.ListingId, stored.Definition.ListingId);
Assert.Equal(fixture.Scope, stored.Definition.Scope);
Assert.Equal(8, stored.Definition.CurrentPlayers);
Assert.Equal(8, stored.Definition.MaximumPlayers);
Assert.True(fixture.Service.Delete(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Succeeded);
Assert.True(fixture.Service.Delete(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Succeeded);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
}
[Fact]
public void AnotherPublisherCannotRenewUpdateOrDeleteListing()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
DedicatedPublisherPrincipal other = fixture.Publisher("publisher-2");
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew(
other,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Error);
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Update(
other,
registration.ListingId,
new()
{
LeaseToken = registration.LeaseToken,
BuildVersion = "1.4.3",
DisplayName = "Hijacked",
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 2 },
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
}).Error);
Assert.True(fixture.Service.Delete(
other,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Succeeded);
Assert.True(fixture.Store.GetListing(registration.ListingId, false).Succeeded);
}
[Fact]
public async Task ConcurrentRenewDeleteCannotResurrectListing()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
using ManualResetEventSlim start = new(false);
Task<SessionServiceResult<RenewLeaseResponse>> renew = Task.Run(() =>
{
start.Wait();
return fixture.Service.Renew(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken });
});
Task<SessionServiceResult<bool>> delete = Task.Run(() =>
{
start.Wait();
return fixture.Service.Delete(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken });
});
start.Set();
await Task.WhenAll(renew, delete);
SessionServiceResult<RenewLeaseResponse> renewResult = await renew;
SessionServiceResult<bool> deleteResult = await delete;
Assert.True(deleteResult.Succeeded);
Assert.Contains(renewResult.Error, new[]
{
RendezvousErrorCode.None,
RendezvousErrorCode.NotFound,
RendezvousErrorCode.Conflict,
});
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
}
[Fact]
public void AbandonedRegistrationExpiresAndFreesBoundedCapacity()
{
EphemeralStoreOptions options = new()
{
MaxListings = 1,
LeaseLifetime = TimeSpan.FromSeconds(5),
};
using SessionLeaseFixture fixture = new(options);
fixture.Register(fixture.Request("first"));
Assert.Equal(RendezvousErrorCode.CapacityExceeded, fixture.Service.Register(
fixture.Principal,
fixture.Request("second")).Error);
fixture.Clock.Advance(options.LeaseLifetime);
Assert.True(fixture.Service.Register(
fixture.Principal,
fixture.Request("second")).Succeeded);
}
[Fact]
public void LeaseExpiryRemovesMutationAndPresencePaths()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
fixture.BindPresence(registration);
fixture.Clock.Advance(fixture.StoreOptions.LeaseLifetime);
Assert.Empty(fixture.Browse());
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Error);
Assert.Equal(StoreResultCode.NotFound, fixture.BindPresence(registration).Code);
}
[Fact]
public void LossOfAtomicStateFailsLeaseMutationClosed()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
fixture.Store.MarkUnavailable();
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, fixture.Service.Renew(
fixture.Principal,
registration.ListingId,
new() { LeaseToken = registration.LeaseToken }).Error);
}
[Fact]
public void InvalidPolicyBoundInputsReturnStableTypedErrors()
{
using SessionLeaseFixture fixture = new();
RegisterSessionRequest capacity = fixture.Request("bad-capacity");
capacity.Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 1 };
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
capacity).Error);
RegisterSessionRequest protocol = fixture.Request("bad-protocol");
protocol.ProtocolVersion = 8;
Assert.Equal(RendezvousErrorCode.IncompatibleProtocol, fixture.Service.Register(
fixture.Principal,
protocol).Error);
RegisterSessionRequest region = fixture.Request("bad-region");
region.RegionId = new("us-east");
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
region).Error);
RegisterSessionRequest visibility = fixture.Request("bad-visibility");
visibility.Visibility = (ListingVisibility)99;
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
visibility).Error);
RegisterSessionRequest metadata = fixture.Request("bad-metadata");
metadata.Metadata = new Dictionary<string, string> { ["unknown"] = "value" };
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
metadata).Error);
RegisterSessionRequest build = fixture.Request("bad-build");
build.BuildVersion = " ";
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register(
fixture.Principal,
build).Error);
}
}
@@ -0,0 +1,93 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
using FinalFactory.Rendezvous.Tests.State;
namespace FinalFactory.Rendezvous.Tests.Sessions;
internal sealed class SessionLeaseFixture : IDisposable
{
private int _sequence;
public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null)
{
StoreOptions = storeOptions ?? new EphemeralStoreOptions();
Clock = new();
Store = new(StoreOptions, Clock, Clock);
Capabilities = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
Service = new(
new PublisherAuthorizationService(policies),
Store,
Capabilities,
SessionLeaseTiming.From(StoreOptions),
Clock);
Principal = Publisher("publisher-1");
}
public EphemeralStoreOptions StoreOptions { get; }
public ManualRendezvousClock Clock { get; }
public InMemoryEphemeralRendezvousStore Store { get; }
public EphemeralCapabilityIssuer Capabilities { get; }
public SessionLeaseService Service { get; }
public DedicatedPublisherPrincipal Principal { get; }
public TenantScope Scope { get; } = new(new("space-game"), new("production"));
public DedicatedPublisherPrincipal Publisher(string subject) => new(
subject,
Clock.UtcNow.AddMinutes(10),
Scope.GameId,
Scope.EnvironmentId,
new HashSet<RegionId> { new("eu-central") });
public RegisterSessionRequest Request(string? idempotencyKey = null) => new()
{
IdempotencyKey = idempotencyKey ?? $"register-{Interlocked.Increment(ref _sequence)}",
GameId = Scope.GameId,
EnvironmentId = Scope.EnvironmentId,
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.4.2",
DisplayName = "Europa Relay",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
},
};
public RegisterSessionResponse Register(
RegisterSessionRequest? request = null,
DedicatedPublisherPrincipal? principal = null)
{
SessionServiceResult<RegisterSessionResponse> result = Service.Register(
principal ?? Principal,
request ?? Request());
Assert.True(result.Succeeded);
Assert.NotNull(result.Value);
return result.Value;
}
public StoreResult<StoredListing> BindPresence(RegisterSessionResponse registration)
{
Assert.True(Capabilities.TryFingerprint(
registration.HostPresenceCapability,
out SecretFingerprint fingerprint));
return Store.BindHostPresence(new(
registration.HostPresenceHandle,
fingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.50", 40_000),
new ObservedEndpoint(AddressFamilyKind.Ipv4, "192.168.1.50", 40_000)));
}
public IReadOnlyList<StoredListing> Browse() => Store.BrowseVisibleListings(new(
Scope,
7,
new RegionId("eu-central"))).Value!;
public void Dispose() => Capabilities.Dispose();
}
@@ -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",
}); });
} }
@@ -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);
@@ -217,7 +217,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 +252,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;}
@@ -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}