Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d076c281a | |||
| 1baa1055dc |
+125
-4
@@ -686,8 +686,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -726,8 +746,109 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"501": {
|
"400": {
|
||||||
"description": "Not Implemented",
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/join-attempts/{attemptId}": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Join attempts"
|
||||||
|
],
|
||||||
|
"operationId": "CancelJoinAttempt",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "attemptId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "X-Rendezvous-Client-Punch-Capability",
|
||||||
|
"in": "header",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ them but must not raise them without security review.
|
|||||||
| Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort |
|
| Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort |
|
||||||
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
|
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
|
||||||
| Opaque HTTP credential | 1,024 bytes encoded |
|
| Opaque HTTP credential | 1,024 bytes encoded |
|
||||||
| UDP capability or ticket | 768 bytes encoded, with the complete datagram still at most 1,200 bytes |
|
| UDP capability or connection ticket | 192 base64url characters; NAT punch capabilities also remain below LiteNetLib's 256-character token ceiling; complete datagram at most 1,200 bytes |
|
||||||
| Clock skew | 30 seconds maximum when validating issued/not-before/expiry times |
|
| Clock skew | 30 seconds maximum when validating issued/not-before/expiry times |
|
||||||
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
|
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
|
||||||
| Host presence freshness | 20 seconds |
|
| Host presence freshness | 20 seconds |
|
||||||
|
|||||||
@@ -60,12 +60,14 @@ the supplied ID.
|
|||||||
|
|
||||||
### UDP presence
|
### UDP presence
|
||||||
|
|
||||||
Only a structurally valid `HostPresence` datagram with the issued capability can
|
Only a structurally valid frozen `HostPresence` envelope or native LiteNetLib
|
||||||
refresh presence. The public endpoint is the UDP packet's observed source on the
|
host-presence request with the issued capability can refresh presence. The public
|
||||||
host's gameplay socket; the HTTP API never accepts one. The bounded local candidate
|
endpoint is the UDP packet's observed source on the host's gameplay socket; the
|
||||||
comes from the authenticated datagram. Invalid, unknown, or client-presence packets
|
HTTP API never accepts one. The bounded local candidate comes from the authenticated
|
||||||
receive no response. Presence expiry demotes public visibility but keeps the lease,
|
packet. Invalid or unknown inputs receive no response. ADR 0009 defines the later
|
||||||
so the same handle can restore visibility without changing session identity.
|
attempt-role use of frozen `ClientPresence` and native host/client requests.
|
||||||
|
Presence expiry demotes public visibility but keeps the lease, so the same handle
|
||||||
|
can restore visibility without changing session identity.
|
||||||
|
|
||||||
Public listing responses contain bounded listing data only. They never contain
|
Public listing responses contain bounded listing data only. They never contain
|
||||||
public/local endpoints, lease tokens, presence capabilities, fingerprints, store
|
public/local endpoints, lease tokens, presence capabilities, fingerprints, store
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# ADR 0008: scoped join attempts and one-time connection tickets
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #10
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Join creation is an unauthenticated public operation because v1 does not treat a
|
||||||
|
Rendezvous caller as game identity. The HTTP source address is normalized and
|
||||||
|
converted to a process-keyed opaque subject for idempotency and bounded policy
|
||||||
|
accounting; raw addresses and the derived subject are never returned or logged.
|
||||||
|
A successful request means only that this network client may try to connect to
|
||||||
|
this active session. It does not reserve capacity or grant gameplay admission.
|
||||||
|
|
||||||
|
Creation validates the v1 contract, caller idempotency key, enabled tenant policy,
|
||||||
|
exact gameplay protocol, listing scope, live lease, and fresh authenticated host
|
||||||
|
presence in one atomic store operation. A listing advertised as full remains
|
||||||
|
joinable because its player count is advisory and the game host owns the final
|
||||||
|
capacity, identity, ban, and admission decision.
|
||||||
|
|
||||||
|
Each attempt derives independent host-punch, client-punch, and connection-ticket
|
||||||
|
credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC
|
||||||
|
key, the client subject, the complete canonical request fingerprint, a fresh salt,
|
||||||
|
and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
|
||||||
|
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
|
||||||
|
256-character NAT token ceiling. State retains keyed credential fingerprints,
|
||||||
|
derivation inputs, and salt—not issued plaintext. All diagnostic string
|
||||||
|
representations redact credentials and derivation material.
|
||||||
|
|
||||||
|
The client receives only its punch capability. A host polls its own listing with
|
||||||
|
the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
|
||||||
|
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
|
||||||
|
identical join request returns the same live attempt; changing the request under
|
||||||
|
the same owner/key conflicts. A client may cancel with its punch capability in
|
||||||
|
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the
|
||||||
|
attempt. Listing deletion, expiry, revocation, or process restart removes every
|
||||||
|
associated attempt and credential fingerprint.
|
||||||
|
|
||||||
|
Endpoint binding remains role- and capability-specific. The first endpoint
|
||||||
|
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
|
||||||
|
endpoint or role substitution is rejected. An introduction is consumable once
|
||||||
|
only after both roles bind, so concurrent attempts for the same listing cannot
|
||||||
|
cross-wire.
|
||||||
|
|
||||||
|
The connection ticket is distinct from both punch capabilities and is reproduced
|
||||||
|
only after introduction succeeds. Its window begins at that moment and lasts at
|
||||||
|
most 20 seconds without outliving the 30-second attempt. The server has an atomic
|
||||||
|
fingerprint-consumption seam for mediator tests and revocation. On the game host,
|
||||||
|
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
|
||||||
|
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/
|
||||||
|
revoked/replayed tickets, and zeroes retained digests and key material on disposal.
|
||||||
|
Issue #11 carries the ticket in the authenticated introduction; issue #12 wires
|
||||||
|
authorization and consumption into the caller-owned LiteNetLib coordinator.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A join attempt is transport authorization, never proof of player identity or a
|
||||||
|
game slot.
|
||||||
|
- Network-address-derived subjects are process-local abuse/idempotency scopes,
|
||||||
|
not stable user identifiers; stronger authenticated player scopes require a
|
||||||
|
future game-owned identity contract.
|
||||||
|
- Cancellation after a ticket has reached a host must also revoke that host's
|
||||||
|
local validator entry; coordinator wiring owns that race in issue #12.
|
||||||
|
- Capability and ticket plaintext never enter browser results, state snapshots,
|
||||||
|
logs, metrics, or generated string representations.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0009: authenticated bounded LiteNetLib NAT mediator
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #11
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The server owns one LiteNetLib `NetManager` and its `NatPunchModule` on the
|
||||||
|
configured UDP endpoint. It runs in manual mode with a configured maximum number
|
||||||
|
of datagrams per poll and a short caller-owned poll interval. LiteNetLib events
|
||||||
|
are unsynchronized so authenticated requests are processed immediately on that
|
||||||
|
single polling path rather than accumulated in an unbounded event queue. The
|
||||||
|
mediator never accepts a LiteNetLib gameplay connection or handles application
|
||||||
|
payloads.
|
||||||
|
|
||||||
|
The packet layer also consumes the frozen v1 presence envelope on the same
|
||||||
|
socket. Native NAT requests use a canonical fixed-size 192-character token that
|
||||||
|
binds a role (`HostPresence`, attempt `Host`, or attempt `Client`), mediation
|
||||||
|
handle, and the already-issued capability. Both transports enter one processor
|
||||||
|
and the same atomic store operations. No transport-supplied public address is
|
||||||
|
trusted; the socket source is authoritative.
|
||||||
|
|
||||||
|
LiteNetLib's native NAT packet family also contains introduction-response and
|
||||||
|
punch frames that are appropriate for peers but unsafe on a public mediator: a
|
||||||
|
forged response can name arbitrary destinations. The packet layer therefore
|
||||||
|
decodes only the pinned `NatIntroduceRequest` wire shape and consumes every
|
||||||
|
inbound packet before `NatPunchModule` sees it. The module is outbound-only and
|
||||||
|
may send introductions solely from a completed authorized plan.
|
||||||
|
|
||||||
|
Listing presence refreshes authorize no response. Attempt contributions bind the
|
||||||
|
first observed endpoint for exactly one capability role. Exact duplicates are
|
||||||
|
idempotent; a different endpoint, the opposite role, an expired/cancelled
|
||||||
|
attempt, or a stale listing presence cannot replace it. The introduction is
|
||||||
|
consumed atomically only after both roles bind and their observed address
|
||||||
|
families match, preventing concurrent attempts for one listing from cross-wiring.
|
||||||
|
|
||||||
|
A reported local candidate is eligible only when it is RFC 1918 IPv4 or IPv6
|
||||||
|
unique-local unicast, matches the observed family, and both peers have the same
|
||||||
|
observed public address. Otherwise `NatIntroduce` receives the observed public
|
||||||
|
endpoint in the local slot. Loopback, link-local, multicast, unspecified,
|
||||||
|
documentation IPv6, global-address claims, and cross-family claims are never
|
||||||
|
disclosed as local targets. IPv4 is required; observed global IPv6 can be used
|
||||||
|
when both peers contribute IPv6, without claiming guaranteed IPv6 NAT traversal.
|
||||||
|
|
||||||
|
The introduction carries only the distinct connection ticket and is emitted at
|
||||||
|
most once to each verified observed endpoint. The fixed authenticated native
|
||||||
|
request and bounded frozen envelope keep the combined response bytes within the
|
||||||
|
2.0 verified amplification budget; unauthenticated inputs receive zero bytes.
|
||||||
|
Malformed, truncated, oversized, spoofed, or unrelated LiteNetLib packets do not
|
||||||
|
grow Rendezvous state. Raw endpoints and credentials are never logged or exposed
|
||||||
|
through diagnostic string representations.
|
||||||
|
|
||||||
|
Frozen IPv6 listing-presence refresh remains valid because it emits no response.
|
||||||
|
IPv6 attempt roles require the fixed-size native LiteNetLib request; accepting the
|
||||||
|
short frozen envelope would exceed the 2.0 byte budget for two IPv6 introduction
|
||||||
|
frames. The required IPv4 listen address and optional IPv6 listen address are
|
||||||
|
configured separately so enabling one family never widens the other family to a
|
||||||
|
wildcard bind.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Hosts refresh listing presence and answer invitations from their actual
|
||||||
|
gameplay socket; a separate mediator socket would observe the wrong mapping.
|
||||||
|
- Caller-owned SDK coordination in #12 must poll the host invitation endpoint,
|
||||||
|
send the corresponding native role token, and consume the returned ticket.
|
||||||
|
- UDP loss can prevent traversal, but it cannot cause an arbitrary destination,
|
||||||
|
replay, role substitution, or cross-attempt introduction.
|
||||||
@@ -10,6 +10,8 @@ decision requires a superseding ADR and corresponding contract/test updates.
|
|||||||
- [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md)
|
- [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md)
|
||||||
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md)
|
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md)
|
||||||
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
|
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
|
||||||
|
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
|
||||||
|
- [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md)
|
||||||
- [Threat model](../security/threat-model.md)
|
- [Threat model](../security/threat-model.md)
|
||||||
- [Security promise and test matrix](../security/control-matrix.md)
|
- [Security promise and test matrix](../security/control-matrix.md)
|
||||||
- [Versioned HTTP and UDP contracts](../contracts/README.md)
|
- [Versioned HTTP and UDP contracts](../contracts/README.md)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ the same value as a required query parameter.
|
|||||||
| `GET` | `/v1/sessions` | Browse compatible public sessions. |
|
| `GET` | `/v1/sessions` | Browse compatible public sessions. |
|
||||||
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
|
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
|
||||||
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
|
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
|
||||||
|
| `DELETE` | `/v1/join-attempts/{attemptId}` | Cancel an attempt using its client punch capability. |
|
||||||
| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
|
| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
|
||||||
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
|
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
|
||||||
| `GET` | `/health/live` | Report that the HTTP process is alive. |
|
| `GET` | `/health/live` | Report that the HTTP process is alive. |
|
||||||
@@ -49,6 +50,11 @@ for mutation operations are carried in their request bodies. Public browser
|
|||||||
responses contain no IP endpoints, lease tokens, punch capabilities, connection
|
responses contain no IP endpoints, lease tokens, punch capabilities, connection
|
||||||
tickets, player identifiers, or gameplay state.
|
tickets, player identifiers, or gameplay state.
|
||||||
|
|
||||||
|
Attempt cancellation sends the short-lived client punch capability in
|
||||||
|
`X-Rendezvous-Client-Punch-Capability`. Join creation uses the observed HTTP
|
||||||
|
source only for a process-keyed, short-lived idempotency/abuse scope; this is not
|
||||||
|
player authentication and is never returned to callers.
|
||||||
|
|
||||||
## Idempotency, cursors, and retries
|
## Idempotency, cursors, and retries
|
||||||
|
|
||||||
Registration and join creation require a caller-generated visible-ASCII
|
Registration and join creation require a caller-generated visible-ASCII
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# UDP presence contract v1
|
# UDP presence and NAT-punch contract v1
|
||||||
|
|
||||||
Tracking: #4
|
Tracking: #4, #11
|
||||||
|
|
||||||
The UDP mediator accepts a single bounded presence envelope from a host or
|
The UDP mediator accepts the frozen bounded presence envelope below and native
|
||||||
client. It associates the authenticated mediation handle with the packet's
|
LiteNetLib NAT-introduction requests. Both forms associate an authenticated
|
||||||
observed public source endpoint and the sender's reported local endpoint. It
|
mediation handle with the packet's observed public source endpoint and the
|
||||||
does not carry gameplay packets.
|
sender's reported local endpoint. Neither form carries gameplay packets.
|
||||||
|
|
||||||
All multi-byte integers use network byte order. UUID bytes use the canonical
|
All multi-byte integers use network byte order. UUID bytes use the canonical
|
||||||
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
|
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
|
||||||
@@ -49,5 +49,48 @@ Capabilities are short-lived, single-purpose, scoped to one mediation handle,
|
|||||||
and compared without exposing them in logs. A valid-looking packet does not
|
and compared without exposing them in logs. A valid-looking packet does not
|
||||||
prove authorization until the capability is checked. Invalid packets receive
|
prove authorization until the capability is checked. Invalid packets receive
|
||||||
no UDP response, preventing the mediator from becoming an amplification oracle.
|
no UDP response, preventing the mediator from becoming an amplification oracle.
|
||||||
Replay, expiry, pairing, and rate-limit policy are defined by later mediator
|
For the frozen envelope, `HostPresence` is resolved against either the listing's
|
||||||
issues; the v1 envelope deliberately leaves no unbounded or reflected payload.
|
host-presence capability or an attempt's host-role capability. `ClientPresence`
|
||||||
|
is resolved only against the attempt's client-role capability. Handles are
|
||||||
|
globally distinct in the active store, so this does not permit role confusion.
|
||||||
|
|
||||||
|
## Native LiteNetLib request token
|
||||||
|
|
||||||
|
A game using LiteNetLib sends `NatPunchModule.SendNatIntroduceRequest` from its
|
||||||
|
gameplay `NetManager`. The `additionalInfo` value is produced by
|
||||||
|
`NatPunchRequestTokenCodec` and is exactly 192 ASCII characters:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rv1:<role>:<32 lowercase handle hex>:<43-character capability><dot padding>
|
||||||
|
```
|
||||||
|
|
||||||
|
`role` is `p` for listing host-presence refresh, `h` for the host side of a join
|
||||||
|
attempt, or `c` for its client side. Padding is canonical and leaves the token
|
||||||
|
below LiteNetLib's 256-character ceiling. Its fixed size also ensures that the
|
||||||
|
two authenticated introduction responses remain within the 2.0 response-byte
|
||||||
|
budget. Tokens with a wrong length, role, handle, capability, or padding receive
|
||||||
|
no response.
|
||||||
|
|
||||||
|
The mediator runs LiteNetLib in bounded manual-poll mode. Its packet layer admits
|
||||||
|
only the pinned native `NatIntroduceRequest` frame, consumes every inbound frame
|
||||||
|
before LiteNetLib can act on it, and uses `NatPunchModule` only to emit authorized
|
||||||
|
introductions. Native and frozen v1 inputs reach the same atomic role/capability
|
||||||
|
checks. Only the packet source is
|
||||||
|
used as the public endpoint. A claimed private candidate is retained only when
|
||||||
|
it is private unicast, matches the observed address family, and both authorized
|
||||||
|
peers were observed behind the same public address; otherwise the observed
|
||||||
|
public endpoint is substituted. IPv4 punching is required. IPv6 sources must be
|
||||||
|
observed global unicast and both roles must use IPv6; IPv6 NAT traversal remains
|
||||||
|
best-effort rather than a v1 release requirement.
|
||||||
|
|
||||||
|
The second valid contribution atomically consumes the introduction and starts
|
||||||
|
the connection-ticket lifetime. `NatIntroduce` is called once with the distinct
|
||||||
|
43-character connection ticket. Reordered and exact duplicate requests are
|
||||||
|
idempotent. Endpoint substitution, cross-role use, stale host presence, expired
|
||||||
|
or cancelled attempts, malformed packets, and gameplay payloads produce no
|
||||||
|
introduction and create no mediator queue or endpoint state.
|
||||||
|
|
||||||
|
Frozen envelopes may refresh listing presence over IPv6 because that operation
|
||||||
|
has no response. IPv6 attempt contributions must use the fixed-size native token;
|
||||||
|
the shorter frozen IPv6 envelope cannot fund two IPv6 introduction frames within
|
||||||
|
the 2.0 response-byte ceiling and is therefore dropped without response.
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum ConnectionTicketConsumptionResult
|
||||||
|
{
|
||||||
|
Accepted = 1,
|
||||||
|
NotFound = 2,
|
||||||
|
Expired = 3,
|
||||||
|
Rejected = 4,
|
||||||
|
AlreadyConsumed = 5,
|
||||||
|
Revoked = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ConnectionTicketValidator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<JoinAttemptId, TicketEntry> _tickets = [];
|
||||||
|
private readonly int _maximumAuthorizedTickets;
|
||||||
|
private readonly IConnectionTicketClock _clock;
|
||||||
|
private readonly byte[] _fingerprintKey = new byte[32];
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public ConnectionTicketValidator(int maximumAuthorizedTickets = 1_024)
|
||||||
|
: this(maximumAuthorizedTickets, new SystemConnectionTicketClock())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ConnectionTicketValidator(
|
||||||
|
int maximumAuthorizedTickets,
|
||||||
|
IConnectionTicketClock clock)
|
||||||
|
{
|
||||||
|
if (maximumAuthorizedTickets is < 1 or > 10_000)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maximumAuthorizedTickets));
|
||||||
|
}
|
||||||
|
|
||||||
|
_maximumAuthorizedTickets = maximumAuthorizedTickets;
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
RandomNumberGenerator.Fill(_fingerprintKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAuthorize(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string connectionTicket,
|
||||||
|
DateTimeOffset expiresAt)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(connectionTicket)
|
||||||
|
|| expiresAt <= now)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveExpired(now);
|
||||||
|
byte[] fingerprint = Fingerprint(connectionTicket);
|
||||||
|
if (_tickets.TryGetValue(attemptId, out TicketEntry? current))
|
||||||
|
{
|
||||||
|
bool idempotent = current.State == TicketState.Active
|
||||||
|
&& current.ExpiresAt == expiresAt
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(current.Fingerprint, fingerprint);
|
||||||
|
CryptographicOperations.ZeroMemory(fingerprint);
|
||||||
|
return idempotent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tickets.Count >= _maximumAuthorizedTickets)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(fingerprint);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickets.Add(attemptId, new(fingerprint, expiresAt));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConnectionTicketConsumptionResult Consume(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
|
||||||
|
{
|
||||||
|
RemoveExpired(now);
|
||||||
|
return ConnectionTicketConsumptionResult.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.ExpiresAt <= now)
|
||||||
|
{
|
||||||
|
Remove(attemptId, entry);
|
||||||
|
return ConnectionTicketConsumptionResult.Expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State == TicketState.Revoked)
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.Revoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State == TicketState.Consumed)
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.AlreadyConsumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] supplied = Fingerprint(connectionTicket);
|
||||||
|
bool matches = CryptographicOperations.FixedTimeEquals(entry.Fingerprint, supplied);
|
||||||
|
CryptographicOperations.ZeroMemory(supplied);
|
||||||
|
if (!matches)
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.State = TicketState.Consumed;
|
||||||
|
return ConnectionTicketConsumptionResult.Accepted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Revoke(JoinAttemptId attemptId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
RemoveExpired(_clock.UtcNow);
|
||||||
|
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.State = TicketState.Revoked;
|
||||||
|
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (TicketEntry entry in _tickets.Values)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickets.Clear();
|
||||||
|
CryptographicOperations.ZeroMemory(_fingerprintKey);
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[ConnectionTicketValidator: tickets and key redacted]";
|
||||||
|
|
||||||
|
private byte[] Fingerprint(string ticket)
|
||||||
|
{
|
||||||
|
byte[] encoded = Encoding.ASCII.GetBytes(ticket);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HMACSHA256 hmac = new(_fingerprintKey);
|
||||||
|
return hmac.ComputeHash(encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveExpired(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
foreach (KeyValuePair<JoinAttemptId, TicketEntry> item in _tickets
|
||||||
|
.Where(item => item.Value.ExpiresAt <= now)
|
||||||
|
.ToArray())
|
||||||
|
{
|
||||||
|
Remove(item.Key, item.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Remove(JoinAttemptId attemptId, TicketEntry entry)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||||
|
_tickets.Remove(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(ConnectionTicketValidator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TicketEntry(byte[] fingerprint, DateTimeOffset expiresAt)
|
||||||
|
{
|
||||||
|
public byte[] Fingerprint { get; } = fingerprint;
|
||||||
|
public DateTimeOffset ExpiresAt { get; } = expiresAt;
|
||||||
|
public TicketState State { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TicketState
|
||||||
|
{
|
||||||
|
Active = 0,
|
||||||
|
Consumed = 1,
|
||||||
|
Revoked = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IConnectionTicketClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemConnectionTicketClock : IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
||||||
@@ -42,6 +42,21 @@ if (!registered.IsSuccess || registered.Value is null)
|
|||||||
Load `publisherCredential` from the game's deployment secret boundary; never
|
Load `publisherCredential` from the game's deployment secret boundary; never
|
||||||
embed it in a client build or source control. A successful registration returns a
|
embed it in a client build or source control. A successful registration returns a
|
||||||
`PublishedSession` containing the lease and host-presence capabilities.
|
`PublishedSession` containing the lease and host-presence capabilities.
|
||||||
|
Send a periodic presence request from the host's gameplay `NetManager` using the
|
||||||
|
server-controlled refresh interval and the fixed-size native token:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
string presenceToken = NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.HostPresence,
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
session.HostPresenceCapability);
|
||||||
|
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
The same codec creates `Host` tokens for host-polled invitations and `Client`
|
||||||
|
tokens for a created join attempt. Always send them from the same LiteNetLib
|
||||||
|
socket that will carry the direct game connection; the mediator ignores any
|
||||||
|
caller-supplied public endpoint.
|
||||||
|
|
||||||
Lease renewal is explicit and caller-controlled:
|
Lease renewal is explicit and caller-controlled:
|
||||||
|
|
||||||
@@ -58,4 +73,22 @@ it when hosting stops. Use `IRendezvousPublisherClient` and
|
|||||||
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
|
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
|
||||||
the requests and responses it creates but never disposes the supplied `HttpClient`.
|
the requests and responses it creates but never disposes the supplied `HttpClient`.
|
||||||
|
|
||||||
See the repository's ADR 0007 for retry, paging, ownership, and failure semantics.
|
The host-side `ConnectionTicketValidator` is a bounded, thread-safe one-time gate.
|
||||||
|
Authorize only tickets delivered by the authenticated Rendezvous introduction,
|
||||||
|
then consume the exact ticket presented by the direct LiteNetLib connection:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ConnectionTicketValidator tickets = new();
|
||||||
|
tickets.TryAuthorize(attemptId, expectedTicket, expiresAt);
|
||||||
|
ConnectionTicketConsumptionResult admission = tickets.Consume(
|
||||||
|
attemptId,
|
||||||
|
presentedTicket);
|
||||||
|
```
|
||||||
|
|
||||||
|
An `Accepted` ticket authorizes only this connection attempt. The game must still
|
||||||
|
apply its own player identity, capacity, ban, and gameplay admission rules. Revoke
|
||||||
|
the attempt on cancellation and dispose the validator during host shutdown so its
|
||||||
|
keyed ticket digests are zeroed.
|
||||||
|
|
||||||
|
See the repository's ADR 0007 for HTTP ownership/retry semantics and ADR 0008 for
|
||||||
|
join-capability and connection-ticket security semantics.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public static class ContractLimits
|
|||||||
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
|
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
|
||||||
public const int UdpCapabilityMaxCharacters = 192;
|
public const int UdpCapabilityMaxCharacters = 192;
|
||||||
public const int ConnectionTicketMaxCharacters = 192;
|
public const int ConnectionTicketMaxCharacters = 192;
|
||||||
|
public const int DerivedCredentialCharacters = 43;
|
||||||
|
public const int NatPunchRequestTokenCharacters = 192;
|
||||||
public const int LiteNetLibNatTokenMaxCharacters = 256;
|
public const int LiteNetLibNatTokenMaxCharacters = 256;
|
||||||
public const int SessionCapacityMaxPlayers = 10_000;
|
public const int SessionCapacityMaxPlayers = 10_000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
public enum NatPunchPeerRole
|
||||||
|
{
|
||||||
|
HostPresence = 1,
|
||||||
|
Host = 2,
|
||||||
|
Client = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NatPunchRequestToken
|
||||||
|
{
|
||||||
|
public NatPunchPeerRole Role { get; set; }
|
||||||
|
public MediationHandle MediationHandle { get; set; }
|
||||||
|
public string Capability { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override string ToString() => "[NatPunchRequestToken: capability redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NatPunchRequestTokenCodec
|
||||||
|
{
|
||||||
|
public const int EncodedLength = ContractLimits.NatPunchRequestTokenCharacters;
|
||||||
|
|
||||||
|
private const string VersionPrefix = "rv1:";
|
||||||
|
private const int HandleLength = 32;
|
||||||
|
private const int CapabilityLength = ContractLimits.DerivedCredentialCharacters;
|
||||||
|
private const char Separator = ':';
|
||||||
|
private const char Padding = '.';
|
||||||
|
|
||||||
|
public static string Encode(
|
||||||
|
NatPunchPeerRole role,
|
||||||
|
MediationHandle mediationHandle,
|
||||||
|
string capability)
|
||||||
|
{
|
||||||
|
if (!TryGetRoleCode(role, out char roleCode)
|
||||||
|
|| mediationHandle.Value == Guid.Empty
|
||||||
|
|| capability is null
|
||||||
|
|| capability.Length != CapabilityLength
|
||||||
|
|| !ContractValidation.IsCapabilityValid(capability))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The NAT punch request token fields are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string payload = string.Concat(
|
||||||
|
VersionPrefix,
|
||||||
|
roleCode,
|
||||||
|
Separator,
|
||||||
|
mediationHandle.Value.ToString("N"),
|
||||||
|
Separator,
|
||||||
|
capability);
|
||||||
|
return payload.PadRight(EncodedLength, Padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(string? encoded, out NatPunchRequestToken? token)
|
||||||
|
{
|
||||||
|
token = null;
|
||||||
|
if (encoded is null
|
||||||
|
|| encoded.Length != EncodedLength
|
||||||
|
|| !encoded.StartsWith(VersionPrefix, StringComparison.Ordinal)
|
||||||
|
|| !TryParseRole(encoded[VersionPrefix.Length], out NatPunchPeerRole role))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int roleSeparator = VersionPrefix.Length + 1;
|
||||||
|
int handleOffset = roleSeparator + 1;
|
||||||
|
int capabilitySeparator = handleOffset + HandleLength;
|
||||||
|
int capabilityOffset = capabilitySeparator + 1;
|
||||||
|
int paddingOffset = capabilityOffset + CapabilityLength;
|
||||||
|
string handleText = encoded.Substring(handleOffset, HandleLength);
|
||||||
|
if (encoded[roleSeparator] != Separator
|
||||||
|
|| encoded[capabilitySeparator] != Separator
|
||||||
|
|| !Guid.TryParseExact(handleText, "N", out Guid handle)
|
||||||
|
|| handle == Guid.Empty
|
||||||
|
|| !string.Equals(handleText, handle.ToString("N"), StringComparison.Ordinal)
|
||||||
|
|| !ContainsOnlyPadding(encoded, paddingOffset))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string capability = encoded.Substring(capabilityOffset, CapabilityLength);
|
||||||
|
if (!ContractValidation.IsCapabilityValid(capability))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = new NatPunchRequestToken
|
||||||
|
{
|
||||||
|
Role = role,
|
||||||
|
MediationHandle = new MediationHandle(handle),
|
||||||
|
Capability = capability,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetRoleCode(NatPunchPeerRole role, out char code)
|
||||||
|
{
|
||||||
|
code = role switch
|
||||||
|
{
|
||||||
|
NatPunchPeerRole.HostPresence => 'p',
|
||||||
|
NatPunchPeerRole.Host => 'h',
|
||||||
|
NatPunchPeerRole.Client => 'c',
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
return code != default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseRole(char code, out NatPunchPeerRole role)
|
||||||
|
{
|
||||||
|
role = code switch
|
||||||
|
{
|
||||||
|
'p' => NatPunchPeerRole.HostPresence,
|
||||||
|
'h' => NatPunchPeerRole.Host,
|
||||||
|
'c' => NatPunchPeerRole.Client,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
return role != default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsOnlyPadding(string value, int offset)
|
||||||
|
{
|
||||||
|
for (int index = offset; index < value.Length; index++)
|
||||||
|
{
|
||||||
|
if (value[index] != Padding)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed class EphemeralCursorProtector : IDisposable
|
||||||
|
{
|
||||||
|
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public string Protect(string prefix, ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
string content = $"{prefix}.{EncodeBytes(payload)}";
|
||||||
|
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string cursor = $"{content}.{EncodeBytes(signature)}";
|
||||||
|
return ContractValidation.IsCursorValid(cursor)
|
||||||
|
? cursor
|
||||||
|
: throw new InvalidOperationException("The protected cursor exceeds its contract limit.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryUnprotect(string prefix, string? cursor, out byte[] payload)
|
||||||
|
{
|
||||||
|
payload = [];
|
||||||
|
if (_disposed || !ContractValidation.IsCursorValid(cursor))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] segments = cursor!.Split('.');
|
||||||
|
if (segments.Length != 3 || !string.Equals(segments[0], prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] expected = HMACSHA256.HashData(
|
||||||
|
_key,
|
||||||
|
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
|
||||||
|
if (!TryDecodeBytes(segments[2], out byte[] supplied))
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validSignature = supplied.Length == expected.Length
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
|
||||||
|
CryptographicOperations.ZeroMemory(supplied);
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
return validSignature && TryDecodeBytes(segments[1], out payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
CryptographicOperations.ZeroMemory(_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[EphemeralCursorProtector: key redacted]";
|
||||||
|
|
||||||
|
private static string EncodeBytes(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
|
||||||
|
private static bool TryDecodeBytes(string value, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
if (string.IsNullOrEmpty(value)
|
||||||
|
|| value.Any(static character =>
|
||||||
|
character is not (>= 'A' and <= 'Z')
|
||||||
|
and not (>= 'a' and <= 'z')
|
||||||
|
and not (>= '0' and <= '9')
|
||||||
|
and not '-'
|
||||||
|
and not '_'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string padded = value.Replace('-', '+').Replace('_', '/');
|
||||||
|
padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(padded);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
@@ -10,12 +9,10 @@ namespace FinalFactory.Rendezvous.Server.Browser;
|
|||||||
internal sealed class SessionBrowserCursorCodec : IDisposable
|
internal sealed class SessionBrowserCursorCodec : IDisposable
|
||||||
{
|
{
|
||||||
private const string Prefix = "rvc1";
|
private const string Prefix = "rvc1";
|
||||||
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
|
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
||||||
BrowserCursorPayload payload = new()
|
BrowserCursorPayload payload = new()
|
||||||
{
|
{
|
||||||
GameId = query.Scope.GameId.Value,
|
GameId = query.Scope.GameId.Value,
|
||||||
@@ -26,19 +23,14 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
|
|||||||
AfterListingId = after.ToString(),
|
AfterListingId = after.ToString(),
|
||||||
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||||
};
|
};
|
||||||
string encoded = EncodeBytes(JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
string content = $"{Prefix}.{encoded}";
|
|
||||||
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string cursor = $"{content}.{EncodeBytes(signature)}";
|
return _protector.Protect(Prefix, encoded);
|
||||||
return ContractValidation.IsCursorValid(cursor)
|
|
||||||
? cursor
|
|
||||||
: throw new InvalidOperationException("The browser cursor exceeds its contract limit.");
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
CryptographicOperations.ZeroMemory(signature);
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,31 +49,7 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_disposed || !ContractValidation.IsCursorValid(cursor))
|
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
string[] segments = cursor.Split('.');
|
|
||||||
if (segments.Length != 3 || !string.Equals(segments[0], Prefix, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] expected = HMACSHA256.HashData(
|
|
||||||
_key,
|
|
||||||
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
|
|
||||||
if (!TryDecodeBytes(segments[2], out byte[] supplied))
|
|
||||||
{
|
|
||||||
CryptographicOperations.ZeroMemory(expected);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool validSignature = supplied.Length == expected.Length
|
|
||||||
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
|
|
||||||
CryptographicOperations.ZeroMemory(supplied);
|
|
||||||
CryptographicOperations.ZeroMemory(expected);
|
|
||||||
if (!validSignature || !TryDecodeBytes(segments[1], out byte[] encodedPayload))
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -118,64 +86,30 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose() => _protector.Dispose();
|
||||||
{
|
|
||||||
if (!_disposed)
|
|
||||||
{
|
|
||||||
_disposed = true;
|
|
||||||
CryptographicOperations.ZeroMemory(_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
|
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
|
||||||
|
|
||||||
private static string EncodeBytes(ReadOnlySpan<byte> bytes) => Convert
|
|
||||||
.ToBase64String(bytes)
|
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
|
|
||||||
private static bool TryDecodeBytes(string value, out byte[] bytes)
|
|
||||||
{
|
|
||||||
bytes = [];
|
|
||||||
if (string.IsNullOrEmpty(value)
|
|
||||||
|| value.Any(static character =>
|
|
||||||
character is not (>= 'A' and <= 'Z')
|
|
||||||
and not (>= 'a' and <= 'z')
|
|
||||||
and not (>= '0' and <= '9')
|
|
||||||
and not '-'
|
|
||||||
and not '_'))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
string padded = value.Replace('-', '+').Replace('_', '/');
|
|
||||||
padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" };
|
|
||||||
try
|
|
||||||
{
|
|
||||||
bytes = Convert.FromBase64String(padded);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (FormatException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class BrowserCursorPayload
|
internal sealed class BrowserCursorPayload
|
||||||
{
|
{
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string GameId { get; set; } = string.Empty;
|
public string GameId { get; set; } = string.Empty;
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string EnvironmentId { get; set; } = string.Empty;
|
public string EnvironmentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public uint ProtocolVersion { get; set; }
|
public uint ProtocolVersion { get; set; }
|
||||||
|
|
||||||
public string? RegionId { get; set; }
|
public string? RegionId { get; set; }
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public bool ExcludeFull { get; set; }
|
public bool ExcludeFull { get; set; }
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string AfterListingId { get; set; } = string.Empty;
|
public string AfterListingId { get; set; } = string.Empty;
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public long ExpiresAtUnixSeconds { get; set; }
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Net;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
using FinalFactory.Rendezvous.Server.Browser;
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
using FinalFactory.Rendezvous.Server.Sessions;
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
using FinalFactory.Rendezvous.Server.State;
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
@@ -67,7 +69,9 @@ internal static class ContractEndpoints
|
|||||||
.WithName("GetSession");
|
.WithName("GetSession");
|
||||||
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
||||||
.Produces<BrowseHostJoinAttemptsResponse>()
|
.Produces<BrowseHostJoinAttemptsResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("BrowseHostJoinAttempts");
|
.WithName("BrowseHostJoinAttempts");
|
||||||
|
|
||||||
RouteGroupBuilder attempts = endpoints
|
RouteGroupBuilder attempts = endpoints
|
||||||
@@ -76,12 +80,22 @@ internal static class ContractEndpoints
|
|||||||
attempts.MapPost("/", CreateJoinAttempt)
|
attempts.MapPost("/", CreateJoinAttempt)
|
||||||
.Accepts<CreateJoinAttemptRequest>("application/json")
|
.Accepts<CreateJoinAttemptRequest>("application/json")
|
||||||
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("CreateJoinAttempt");
|
.WithName("CreateJoinAttempt");
|
||||||
|
attempts.MapDelete("/{attemptId}", CancelJoinAttempt)
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("CancelJoinAttempt");
|
||||||
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
||||||
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
||||||
.Produces<ReportConnectionOutcomeResponse>()
|
.Produces<ReportConnectionOutcomeResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status501NotImplemented)
|
||||||
.WithName("ReportConnectionOutcome");
|
.WithName("ReportConnectionOutcome");
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
@@ -268,10 +282,55 @@ internal static class ContractEndpoints
|
|||||||
[FromQuery] int contractVersion,
|
[FromQuery] int contractVersion,
|
||||||
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
|
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
|
||||||
[FromQuery] int? pageSize,
|
[FromQuery] int? pageSize,
|
||||||
[FromQuery] string? cursor) => NotImplemented();
|
[FromQuery] string? cursor,
|
||||||
|
[FromServices] JoinAttemptService attempts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
|
||||||
|
listingId,
|
||||||
|
contractVersion,
|
||||||
|
leaseToken,
|
||||||
|
pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||||
|
cursor,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult CreateJoinAttempt([FromBody] CreateJoinAttemptRequest request) =>
|
private static IResult CreateJoinAttempt(
|
||||||
NotImplemented();
|
[FromBody] CreateJoinAttemptRequest request,
|
||||||
|
[FromServices] JoinAttemptService attempts,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress)
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
|
||||||
|
clientSubject,
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult CancelJoinAttempt(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||||
|
[FromServices] JoinAttemptService attempts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
JoinAttemptServiceResult<bool> result = attempts.Cancel(
|
||||||
|
attemptId,
|
||||||
|
clientPunchCapability,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult ReportConnectionOutcome(
|
private static IResult ReportConnectionOutcome(
|
||||||
JoinAttemptId attemptId,
|
JoinAttemptId attemptId,
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptCursorCodec : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "rvj1";
|
||||||
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
|
|
||||||
|
public string Encode(
|
||||||
|
SessionListingId listingId,
|
||||||
|
JoinAttemptId after,
|
||||||
|
DateTimeOffset now)
|
||||||
|
{
|
||||||
|
JoinAttemptCursorPayload payload = new()
|
||||||
|
{
|
||||||
|
ListingId = listingId.ToString(),
|
||||||
|
AfterAttemptId = after.ToString(),
|
||||||
|
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _protector.Protect(Prefix, encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDecode(
|
||||||
|
string? cursor,
|
||||||
|
SessionListingId listingId,
|
||||||
|
DateTimeOffset now,
|
||||||
|
out JoinAttemptId? after)
|
||||||
|
{
|
||||||
|
after = null;
|
||||||
|
if (cursor is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinAttemptCursorPayload? payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.Deserialize<JoinAttemptCursorPayload>(
|
||||||
|
encodedPayload,
|
||||||
|
ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is null
|
||||||
|
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||||
|
|| !string.Equals(payload.ListingId, listingId.ToString(), StringComparison.Ordinal)
|
||||||
|
|| !JoinAttemptId.TryParse(payload.AfterAttemptId, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
after = attemptId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _protector.Dispose();
|
||||||
|
|
||||||
|
public override string ToString() => "[JoinAttemptCursorCodec: key and cursors redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptCursorPayload
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string ListingId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string AfterAttemptId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed record JoinAttemptServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ConnectionTicketGrant(string Ticket, DateTimeOffset ExpiresAt)
|
||||||
|
{
|
||||||
|
public override string ToString() => "[ConnectionTicketGrant: ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptService(
|
||||||
|
GamePolicyRegistry policies,
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
JoinAttemptCursorCodec cursors,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public string CreateAnonymousClientSubject(IPAddress remoteAddress)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(remoteAddress);
|
||||||
|
IPAddress normalized = remoteAddress.IsIPv4MappedToIPv6
|
||||||
|
? remoteAddress.MapToIPv4()
|
||||||
|
: remoteAddress;
|
||||||
|
return capabilities.DeriveOpaqueIdentifier("join-http-client", normalized.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<CreateJoinAttemptResponse> Create(
|
||||||
|
string clientSubject,
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
if (string.IsNullOrWhiteSpace(clientSubject))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A bounded client subject is required.", nameof(clientSubject));
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode validation = ValidateCreate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policies.TryGet(request.GameId, request.EnvironmentId, out GamePolicy? policy)
|
||||||
|
|| policy is null)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.AllowsProtocol(request.ProtocolVersion))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.IncompatibleProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
string requestFingerprint = ComputeRequestFingerprint(request);
|
||||||
|
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||||
|
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|
||||||
|
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(connectionTicket, out SecretFingerprint ticketFingerprint))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
|
||||||
|
"join-attempt-id",
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
|
||||||
|
"join-mediation-handle",
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
StoreResult<StoredJoinAttempt> created = store.CreateJoinAttempt(new()
|
||||||
|
{
|
||||||
|
IdempotencyOwner = clientSubject,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
RequestFingerprint = requestFingerprint,
|
||||||
|
ClientSubject = clientSubject,
|
||||||
|
AttemptId = attemptId,
|
||||||
|
MediationHandle = mediationHandle,
|
||||||
|
Scope = new(request.GameId, request.EnvironmentId),
|
||||||
|
ListingId = request.ListingId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
HostCapabilityFingerprint = hostFingerprint,
|
||||||
|
ClientCapabilityFingerprint = clientFingerprint,
|
||||||
|
ConnectionTicketFingerprint = ticketFingerprint,
|
||||||
|
CapabilityDerivationSalt = derivationSalt,
|
||||||
|
ScopeAttemptLimit = policy.MaxActiveJoinAttempts,
|
||||||
|
}, cancellationToken);
|
||||||
|
if (!created.Succeeded || created.Value is null)
|
||||||
|
{
|
||||||
|
return new(created.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredJoinAttempt persisted = created.Value;
|
||||||
|
clientCapability = Derive(
|
||||||
|
"join-client-punch",
|
||||||
|
persisted.ClientSubject,
|
||||||
|
persisted.IdempotencyKey,
|
||||||
|
persisted.RequestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
if (!capabilities.TryFingerprint(clientCapability, out SecretFingerprint persistedFingerprint)
|
||||||
|
|| persistedFingerprint != persisted.ClientCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its client capability.");
|
||||||
|
}
|
||||||
|
return new(RendezvousErrorCode.None, new CreateJoinAttemptResponse
|
||||||
|
{
|
||||||
|
AttemptId = persisted.AttemptId,
|
||||||
|
MediationHandle = persisted.MediationHandle,
|
||||||
|
ClientPunchCapability = clientCapability,
|
||||||
|
ExpiresAt = persisted.ExpiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> BrowseForHost(
|
||||||
|
SessionListingId listingId,
|
||||||
|
int contractVersion,
|
||||||
|
string? leaseToken,
|
||||||
|
int pageSize,
|
||||||
|
string? cursor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||||
|
|| !ContractValidation.IsPageSizeValid(pageSize)
|
||||||
|
|| !ContractValidation.IsCursorValid(cursor)
|
||||||
|
|| !capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cursors.TryDecode(cursor, listingId, clock.UtcNow, out JoinAttemptId? after))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<IReadOnlyList<StoredJoinAttempt>> found = store.BrowseHostJoinAttempts(new(
|
||||||
|
listingId,
|
||||||
|
leaseFingerprint,
|
||||||
|
pageSize + 1,
|
||||||
|
after), cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasMore = found.Value.Count > pageSize;
|
||||||
|
StoredJoinAttempt[] page = found.Value.Take(pageSize).ToArray();
|
||||||
|
BrowseHostJoinAttemptsResponse response = new()
|
||||||
|
{
|
||||||
|
Items = page.Select(CreateHostAttempt).ToList(),
|
||||||
|
NextCursor = hasMore && page.Length > 0
|
||||||
|
? cursors.Encode(listingId, page[^1].AttemptId, clock.UtcNow)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||||
|
return ContractValidation.IsBrowserResponseSizeValid(encodedBytes)
|
||||||
|
? new(RendezvousErrorCode.None, response)
|
||||||
|
: new(RendezvousErrorCode.CapacityExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<bool> Cancel(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string? clientPunchCapability,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsCapabilityValid(clientPunchCapability)
|
||||||
|
|| !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<bool> cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken);
|
||||||
|
return cancelled.Succeeded
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(cancelled.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<ConnectionTicketGrant> IssueConnectionTicket(
|
||||||
|
StoredJoinAttempt attempt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(attempt);
|
||||||
|
if (!attempt.IntroductionConsumed)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
string ticket = Derive(
|
||||||
|
"connection-ticket",
|
||||||
|
attempt.ClientSubject,
|
||||||
|
attempt.IdempotencyKey,
|
||||||
|
attempt.RequestFingerprint,
|
||||||
|
attempt.CapabilityDerivationSalt);
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(ticket)
|
||||||
|
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|
||||||
|
|| fingerprint != attempt.ConnectionTicketFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its connection ticket.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new(ticket, attempt.ConnectionTicketExpiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private HostJoinAttempt CreateHostAttempt(StoredJoinAttempt attempt)
|
||||||
|
{
|
||||||
|
string capability = Derive(
|
||||||
|
"join-host-punch",
|
||||||
|
attempt.ClientSubject,
|
||||||
|
attempt.IdempotencyKey,
|
||||||
|
attempt.RequestFingerprint,
|
||||||
|
attempt.CapabilityDerivationSalt);
|
||||||
|
if (!ContractValidation.IsCapabilityValid(capability)
|
||||||
|
|| !capabilities.TryFingerprint(capability, out SecretFingerprint fingerprint)
|
||||||
|
|| fingerprint != attempt.HostCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its host capability.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
AttemptId = attempt.AttemptId,
|
||||||
|
MediationHandle = attempt.MediationHandle,
|
||||||
|
HostPunchCapability = capability,
|
||||||
|
ExpiresAt = attempt.ExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||||
|
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| request.ListingId.Value == Guid.Empty
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeRequestFingerprint(CreateJoinAttemptRequest request)
|
||||||
|
{
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(request, ContractJson.Options);
|
||||||
|
byte[] digest = SHA256.HashData(encoded);
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Derive(
|
||||||
|
string purpose,
|
||||||
|
string clientSubject,
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt) => Derive(
|
||||||
|
purpose,
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
|
||||||
|
private string Derive(
|
||||||
|
string purpose,
|
||||||
|
string clientSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt) => capabilities.DeriveCapability(
|
||||||
|
purpose,
|
||||||
|
clientSubject,
|
||||||
|
idempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
|
||||||
|
private static bool CredentialLengthsAreValid(
|
||||||
|
string hostCapability,
|
||||||
|
string clientCapability,
|
||||||
|
string ticket) =>
|
||||||
|
ContractValidation.IsCapabilityValid(hostCapability)
|
||||||
|
&& ContractValidation.IsCapabilityValid(clientCapability)
|
||||||
|
&& ContractValidation.IsConnectionTicketValid(ticket)
|
||||||
|
&& hostCapability.Length == ContractLimits.DerivedCredentialCharacters
|
||||||
|
&& clientCapability.Length == ContractLimits.DerivedCredentialCharacters
|
||||||
|
&& ticket.Length == ContractLimits.DerivedCredentialCharacters
|
||||||
|
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
|
||||||
|
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
|
||||||
|
|
||||||
|
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Net;
|
|||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
using FinalFactory.Rendezvous.Server.Browser;
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
using FinalFactory.Rendezvous.Server.Http;
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
using FinalFactory.Rendezvous.Server.Sessions;
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
using FinalFactory.Rendezvous.Server.State;
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
@@ -122,6 +123,8 @@ else
|
|||||||
builder.Services.AddSingleton<SessionLeaseService>();
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
builder.Services.AddSingleton<SessionBrowserService>();
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +133,19 @@ builder.Services
|
|||||||
.BindConfiguration(UdpMediatorOptions.SectionName)
|
.BindConfiguration(UdpMediatorOptions.SectionName)
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.Validate(
|
.Validate(
|
||||||
options => IPAddress.TryParse(options.ListenAddress, out _),
|
options => IPAddress.TryParse(options.ListenAddress, out IPAddress? address)
|
||||||
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
|
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork,
|
||||||
|
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IPv4 address.")
|
||||||
|
.Validate(
|
||||||
|
options => string.IsNullOrWhiteSpace(options.Ipv6ListenAddress)
|
||||||
|
|| (IPAddress.TryParse(options.Ipv6ListenAddress, out IPAddress? address)
|
||||||
|
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6),
|
||||||
|
$"{UdpMediatorOptions.SectionName}:Ipv6ListenAddress must be an IPv6 address when configured.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
builder.Services.AddSingleton<UdpMediatorService>();
|
builder.Services.AddSingleton<UdpMediatorService>();
|
||||||
if (!isOpenApiGeneration)
|
if (!isOpenApiGeneration)
|
||||||
{
|
{
|
||||||
|
builder.Services.AddSingleton<NatMediationProcessor>();
|
||||||
builder.Services.AddHostedService(static services =>
|
builder.Services.AddHostedService(static services =>
|
||||||
services.GetRequiredService<UdpMediatorService>());
|
services.GetRequiredService<UdpMediatorService>());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ internal sealed class PrincipalCredentialService
|
|||||||
|
|
||||||
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
|
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
|
||||||
{
|
{
|
||||||
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
|
return CredentialValidationResult.Invalid(CredentialValidationError.SignatureInvalid);
|
||||||
}
|
}
|
||||||
|
|
||||||
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
|
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
|
||||||
@@ -441,8 +441,15 @@ internal static class Base64Url
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
bytes = Convert.FromBase64String(padded);
|
bytes = Convert.FromBase64String(padded);
|
||||||
|
if (string.Equals(Encode(bytes), value, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
bytes = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ internal interface ISessionCapabilityService
|
|||||||
string idempotencyKey,
|
string idempotencyKey,
|
||||||
string requestFingerprint,
|
string requestFingerprint,
|
||||||
string derivationSalt);
|
string derivationSalt);
|
||||||
|
string DeriveOpaqueIdentifier(string purpose, string value);
|
||||||
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
|
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +92,19 @@ internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string DeriveOpaqueIdentifier(string purpose, string value)
|
||||||
|
{
|
||||||
|
byte[] digest = Derive(purpose, value);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
|
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
|
||||||
{
|
{
|
||||||
fingerprint = default;
|
fingerprint = default;
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ internal sealed class SessionLeaseService(
|
|||||||
ownerLimit), cancellationToken);
|
ownerLimit), cancellationToken);
|
||||||
if (!created.Succeeded || created.Value is null)
|
if (!created.Succeeded || created.Value is null)
|
||||||
{
|
{
|
||||||
return new(MapStore(created.Code));
|
return new(created.Code.ToContractError());
|
||||||
}
|
}
|
||||||
|
|
||||||
ListingDefinition persisted = created.Value.Definition;
|
ListingDefinition persisted = created.Value.Definition;
|
||||||
@@ -205,7 +205,7 @@ internal sealed class SessionLeaseService(
|
|||||||
ExpiresAt = renewed.Value.LeaseExpiresAt,
|
ExpiresAt = renewed.Value.LeaseExpiresAt,
|
||||||
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||||
})
|
})
|
||||||
: new(MapStore(renewed.Code));
|
: new(renewed.Code.ToContractError());
|
||||||
}
|
}
|
||||||
|
|
||||||
public SessionServiceResult<bool> Update(
|
public SessionServiceResult<bool> Update(
|
||||||
@@ -253,7 +253,7 @@ internal sealed class SessionLeaseService(
|
|||||||
request.Metadata), cancellationToken);
|
request.Metadata), cancellationToken);
|
||||||
return updated.Succeeded
|
return updated.Succeeded
|
||||||
? new(RendezvousErrorCode.None, true)
|
? new(RendezvousErrorCode.None, true)
|
||||||
: new(MapStore(updated.Code));
|
: new(updated.Code.ToContractError());
|
||||||
}
|
}
|
||||||
|
|
||||||
public SessionServiceResult<bool> Delete(
|
public SessionServiceResult<bool> Delete(
|
||||||
@@ -291,7 +291,7 @@ internal sealed class SessionLeaseService(
|
|||||||
publisher.Subject), cancellationToken);
|
publisher.Subject), cancellationToken);
|
||||||
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
|
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
|
||||||
? new(RendezvousErrorCode.None, true)
|
? new(RendezvousErrorCode.None, true)
|
||||||
: new(MapStore(deleted.Code));
|
: new(deleted.Code.ToContractError());
|
||||||
}
|
}
|
||||||
|
|
||||||
private RendezvousErrorCode GetAuthorizedListing(
|
private RendezvousErrorCode GetAuthorizedListing(
|
||||||
@@ -315,7 +315,7 @@ internal sealed class SessionLeaseService(
|
|||||||
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||||
if (!found.Succeeded || found.Value is null)
|
if (!found.Succeeded || found.Value is null)
|
||||||
{
|
{
|
||||||
return MapStore(found.Code);
|
return found.Code.ToContractError();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|
||||||
@@ -403,18 +403,6 @@ internal sealed class SessionLeaseService(
|
|||||||
_ => RendezvousErrorCode.Forbidden,
|
_ => 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)
|
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
|
||||||
{
|
{
|
||||||
RegisterSessionRequest canonical = new()
|
RegisterSessionRequest canonical = new()
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ internal sealed record EphemeralStoreOptions
|
|||||||
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
|
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
|
||||||
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||||
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
public TimeSpan ConnectionTicketLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||||
public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
|
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
|
||||||
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
@@ -50,9 +51,17 @@ internal sealed record EphemeralStoreOptions
|
|||||||
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
|
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
|
||||||
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
|
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
|
||||||
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
|
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
|
||||||
|
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
|
||||||
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
||||||
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
||||||
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
||||||
|
if (ConnectionTicketLifetime > JoinAttemptLifetime)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(ConnectionTicketLifetime),
|
||||||
|
"Connection tickets cannot outlive their join attempt.");
|
||||||
|
}
|
||||||
|
|
||||||
if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
|
if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
|
||||||
{
|
{
|
||||||
throw new ArgumentOutOfRangeException(
|
throw new ArgumentOutOfRangeException(
|
||||||
@@ -246,7 +255,11 @@ internal sealed record CreateJoinAttemptCommand
|
|||||||
public required uint ProtocolVersion { get; init; }
|
public required uint ProtocolVersion { get; init; }
|
||||||
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||||
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
||||||
|
|
||||||
|
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record AttemptEndpointBinding(
|
internal sealed record AttemptEndpointBinding(
|
||||||
@@ -261,12 +274,28 @@ internal sealed record StoredJoinAttempt
|
|||||||
public required SessionListingId ListingId { get; init; }
|
public required SessionListingId ListingId { get; init; }
|
||||||
public required string ClientSubject { get; init; }
|
public required string ClientSubject { get; init; }
|
||||||
public required uint ProtocolVersion { get; init; }
|
public required uint ProtocolVersion { get; init; }
|
||||||
|
public required string IdempotencyKey { get; init; }
|
||||||
|
public required string RequestFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||||
public required DateTimeOffset ExpiresAt { get; init; }
|
public required DateTimeOffset ExpiresAt { get; init; }
|
||||||
|
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
||||||
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
||||||
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||||
public required bool IntroductionConsumed { get; init; }
|
public required bool IntroductionConsumed { get; init; }
|
||||||
|
public required bool ConnectionTicketConsumed { get; init; }
|
||||||
|
|
||||||
|
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record HostJoinAttemptQuery(
|
||||||
|
SessionListingId ListingId,
|
||||||
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
int MaximumResults,
|
||||||
|
JoinAttemptId? AfterAttemptId = null);
|
||||||
|
|
||||||
internal sealed record BindAttemptEndpointCommand(
|
internal sealed record BindAttemptEndpointCommand(
|
||||||
MediationHandle Handle,
|
MediationHandle Handle,
|
||||||
AttemptPeerRole Role,
|
AttemptPeerRole Role,
|
||||||
@@ -275,9 +304,20 @@ internal sealed record BindAttemptEndpointCommand(
|
|||||||
ObservedEndpoint? LocalEndpoint);
|
ObservedEndpoint? LocalEndpoint);
|
||||||
|
|
||||||
internal sealed record IntroductionEndpoints(
|
internal sealed record IntroductionEndpoints(
|
||||||
JoinAttemptId AttemptId,
|
StoredJoinAttempt Attempt,
|
||||||
AttemptEndpointBinding Host,
|
AttemptEndpointBinding Host,
|
||||||
AttemptEndpointBinding Client);
|
AttemptEndpointBinding Client)
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId => Attempt.AttemptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record CancelJoinAttemptCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ClientCapabilityFingerprint);
|
||||||
|
|
||||||
|
internal sealed record ConsumeConnectionTicketCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ConnectionTicketFingerprint);
|
||||||
|
|
||||||
internal sealed record ReplayConsumption(
|
internal sealed record ReplayConsumption(
|
||||||
string Namespace,
|
string Namespace,
|
||||||
@@ -316,8 +356,11 @@ internal interface IEphemeralRendezvousStore
|
|||||||
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
|
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
|
||||||
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
|
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
|
||||||
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||||
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
||||||
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
|
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
|
||||||
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
|
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
|
||||||
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
|
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
|
||||||
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
|
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace FinalFactory.Rendezvous.Server.State;
|
|||||||
|
|
||||||
internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousStore
|
internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousStore
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan UdpMaintenanceInterval = TimeSpan.FromSeconds(1);
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly EphemeralStoreOptions _options;
|
private readonly EphemeralStoreOptions _options;
|
||||||
private readonly IMonotonicClock _monotonicClock;
|
private readonly IMonotonicClock _monotonicClock;
|
||||||
@@ -19,6 +20,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
|
||||||
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
|
||||||
private TimeSpan? _drainDeadline;
|
private TimeSpan? _drainDeadline;
|
||||||
|
private TimeSpan _nextUdpMaintenance;
|
||||||
|
private long _maintenanceSweepCount;
|
||||||
private bool _available = true;
|
private bool _available = true;
|
||||||
|
|
||||||
public InMemoryEphemeralRendezvousStore(
|
public InMemoryEphemeralRendezvousStore(
|
||||||
@@ -38,6 +41,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Guid InstanceId { get; }
|
public Guid InstanceId { get; }
|
||||||
|
internal long MaintenanceSweepCount => Interlocked.Read(ref _maintenanceSweepCount);
|
||||||
|
|
||||||
public bool IsAvailable
|
public bool IsAvailable
|
||||||
{
|
{
|
||||||
@@ -270,6 +274,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
if (!_presenceHandles.TryGetValue(command.Handle, out SessionListingId listingId)
|
if (!_presenceHandles.TryGetValue(command.Handle, out SessionListingId listingId)
|
||||||
|| !_listings.TryGetValue(listingId, out ListingEntry? entry)
|
|| !_listings.TryGetValue(listingId, out ListingEntry? entry)
|
||||||
|
|| entry.LeaseDeadline <= now
|
||||||
|| entry.Definition.HostPresenceFingerprint != command.CapabilityFingerprint)
|
|| entry.Definition.HostPresenceFingerprint != command.CapabilityFingerprint)
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
@@ -285,7 +290,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
command.LocalEndpoint,
|
command.LocalEndpoint,
|
||||||
now + _options.PresenceLifetime);
|
now + _options.PresenceLifetime);
|
||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
return new(StoreResultCode.Success, Snapshot(entry));
|
||||||
}, cancellationToken);
|
}, cancellationToken, eagerCleanup: false);
|
||||||
|
|
||||||
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
|
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
|
||||||
VisibleListingQuery query,
|
VisibleListingQuery query,
|
||||||
@@ -390,9 +395,74 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.Success, Snapshot(attempt));
|
return new(StoreResultCode.Success, Snapshot(attempt));
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(
|
||||||
|
HostJoinAttemptQuery query,
|
||||||
|
CancellationToken cancellationToken = default) => Atomic<IReadOnlyList<StoredJoinAttempt>>(_ =>
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
if (query.ListingId.Value == Guid.Empty
|
||||||
|
|| !query.LeaseFingerprint.IsValid
|
||||||
|
|| query.MaximumResults is < 1 or > ContractLimits.BrowserPageMaxItems + 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Host attempt query invariants are invalid.", nameof(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_available)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_listings.TryGetValue(query.ListingId, out ListingEntry? listing)
|
||||||
|
|| listing.Definition.LeaseFingerprint != query.LeaseFingerprint)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values
|
||||||
|
.Where(entry => entry.Command.ListingId == query.ListingId
|
||||||
|
&& !entry.IntroductionConsumed
|
||||||
|
&& (!query.AfterAttemptId.HasValue
|
||||||
|
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
|
||||||
|
.OrderBy(static entry => entry.Command.AttemptId.Value)
|
||||||
|
.Take(query.MaximumResults)
|
||||||
|
.Select(Snapshot)
|
||||||
|
.ToArray();
|
||||||
|
return new(StoreResultCode.Success, attempts);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public StoreResult<bool> CancelJoinAttempt(
|
||||||
|
CancelJoinAttemptCommand command,
|
||||||
|
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
if (command.AttemptId.Value == Guid.Empty || !command.ClientCapabilityFingerprint.IsValid)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Join cancellation invariants are invalid.", nameof(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_available)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|
||||||
|
|| attempt.ClientCapabilityFingerprint != command.ClientCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.IntroductionConsumed)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveAttempt(command.AttemptId);
|
||||||
|
return new(StoreResultCode.Success, true);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
|
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
|
||||||
BindAttemptEndpointCommand command,
|
BindAttemptEndpointCommand command,
|
||||||
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ =>
|
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(now =>
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
if (command.Handle.Value == Guid.Empty
|
if (command.Handle.Value == Guid.Empty
|
||||||
@@ -409,7 +479,13 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|
||||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
|
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
||||||
|
|| attempt.Deadline <= now)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasFreshHostPresence(attempt, now))
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
}
|
}
|
||||||
@@ -443,11 +519,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new(StoreResultCode.Success, Snapshot(attempt));
|
return new(StoreResultCode.Success, Snapshot(attempt));
|
||||||
}, cancellationToken);
|
}, cancellationToken, eagerCleanup: false);
|
||||||
|
|
||||||
public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
|
public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
|
||||||
MediationHandle handle,
|
MediationHandle handle,
|
||||||
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(_ =>
|
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(now =>
|
||||||
{
|
{
|
||||||
if (!_available)
|
if (!_available)
|
||||||
{
|
{
|
||||||
@@ -455,7 +531,13 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|
||||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
|
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
||||||
|
|| attempt.Deadline <= now)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasFreshHostPresence(attempt, now))
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
}
|
}
|
||||||
@@ -471,10 +553,55 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
attempt.IntroductionConsumed = true;
|
attempt.IntroductionConsumed = true;
|
||||||
|
TimeSpan ticketLifetime = TimeSpan.FromTicks(Math.Min(
|
||||||
|
_options.ConnectionTicketLifetime.Ticks,
|
||||||
|
(attempt.Deadline - now).Ticks));
|
||||||
|
attempt.TicketDeadline = now + ticketLifetime;
|
||||||
|
attempt.TicketWallExpiresAt = WallDeadline(now, ticketLifetime);
|
||||||
return new(StoreResultCode.Success, new(
|
return new(StoreResultCode.Success, new(
|
||||||
attempt.Command.AttemptId,
|
Snapshot(attempt),
|
||||||
attempt.HostEndpoint,
|
attempt.HostEndpoint,
|
||||||
attempt.ClientEndpoint));
|
attempt.ClientEndpoint));
|
||||||
|
}, cancellationToken, eagerCleanup: false);
|
||||||
|
|
||||||
|
public StoreResult<bool> ConsumeConnectionTicket(
|
||||||
|
ConsumeConnectionTicketCommand command,
|
||||||
|
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
if (command.AttemptId.Value == Guid.Empty || !command.ConnectionTicketFingerprint.IsValid)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Connection ticket invariants are invalid.", nameof(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_available)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|
||||||
|
|| attempt.ConnectionTicketFingerprint != command.ConnectionTicketFingerprint)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attempt.IntroductionConsumed)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.Expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.ConnectionTicketConsumed)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.ReplayRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt.ConnectionTicketConsumed = true;
|
||||||
|
return new(StoreResultCode.Success, true);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
public StoreResult<bool> ConsumeReplay(
|
public StoreResult<bool> ConsumeReplay(
|
||||||
@@ -582,18 +709,38 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private StoreResult<T> Atomic<T>(Func<TimeSpan, StoreResult<T>> operation, CancellationToken cancellationToken)
|
private StoreResult<T> Atomic<T>(
|
||||||
|
Func<TimeSpan, StoreResult<T>> operation,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
bool eagerCleanup = true)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
TimeSpan now = _monotonicClock.Elapsed;
|
TimeSpan now = _monotonicClock.Elapsed;
|
||||||
|
// Authenticated UDP duplicates need O(1) store work. Their operations
|
||||||
|
// check exact resource deadlines and amortize physical expiry removal.
|
||||||
|
bool drainExpired = _drainDeadline is TimeSpan drainDeadline
|
||||||
|
&& now >= drainDeadline;
|
||||||
|
if (drainExpired || eagerCleanup || now >= _nextUdpMaintenance)
|
||||||
|
{
|
||||||
Cleanup(now);
|
Cleanup(now);
|
||||||
|
_nextUdpMaintenance = now + UdpMaintenanceInterval;
|
||||||
|
}
|
||||||
|
|
||||||
return operation(now);
|
return operation(now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool HasFreshHostPresence(AttemptEntry attempt, TimeSpan now) =>
|
||||||
|
_listings.TryGetValue(attempt.Command.ListingId, out ListingEntry? listing)
|
||||||
|
&& listing.LeaseDeadline > now
|
||||||
|
&& _presence.TryGetValue(
|
||||||
|
listing.Definition.HostPresenceHandle,
|
||||||
|
out PresenceEntry? presence)
|
||||||
|
&& presence.Deadline > now;
|
||||||
|
|
||||||
private StoreResult<T>? CheckNewWorkAdmission<T>(string subject)
|
private StoreResult<T>? CheckNewWorkAdmission<T>(string subject)
|
||||||
{
|
{
|
||||||
if (!_available)
|
if (!_available)
|
||||||
@@ -613,6 +760,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
private void Cleanup(TimeSpan now)
|
private void Cleanup(TimeSpan now)
|
||||||
{
|
{
|
||||||
|
_maintenanceSweepCount++;
|
||||||
if (_drainDeadline is TimeSpan drainDeadline && now >= drainDeadline)
|
if (_drainDeadline is TimeSpan drainDeadline && now >= drainDeadline)
|
||||||
{
|
{
|
||||||
ClearActiveState();
|
ClearActiveState();
|
||||||
@@ -714,10 +862,18 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
ListingId = entry.Command.ListingId,
|
ListingId = entry.Command.ListingId,
|
||||||
ClientSubject = entry.Command.ClientSubject,
|
ClientSubject = entry.Command.ClientSubject,
|
||||||
ProtocolVersion = entry.Command.ProtocolVersion,
|
ProtocolVersion = entry.Command.ProtocolVersion,
|
||||||
|
IdempotencyKey = entry.Command.IdempotencyKey,
|
||||||
|
RequestFingerprint = entry.Command.RequestFingerprint,
|
||||||
|
CapabilityDerivationSalt = entry.Command.CapabilityDerivationSalt,
|
||||||
|
HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint,
|
||||||
|
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
|
||||||
|
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
|
||||||
ExpiresAt = entry.WallExpiresAt,
|
ExpiresAt = entry.WallExpiresAt,
|
||||||
|
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
|
||||||
HostEndpoint = entry.HostEndpoint,
|
HostEndpoint = entry.HostEndpoint,
|
||||||
ClientEndpoint = entry.ClientEndpoint,
|
ClientEndpoint = entry.ClientEndpoint,
|
||||||
IntroductionConsumed = entry.IntroductionConsumed,
|
IntroductionConsumed = entry.IntroductionConsumed,
|
||||||
|
ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
||||||
@@ -789,6 +945,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|| command.ProtocolVersion == 0
|
|| command.ProtocolVersion == 0
|
||||||
|| !command.HostCapabilityFingerprint.IsValid
|
|| !command.HostCapabilityFingerprint.IsValid
|
||||||
|| !command.ClientCapabilityFingerprint.IsValid
|
|| !command.ClientCapabilityFingerprint.IsValid
|
||||||
|
|| !command.ConnectionTicketFingerprint.IsValid
|
||||||
|
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|
||||||
|| command.ScopeAttemptLimit <= 0)
|
|| command.ScopeAttemptLimit <= 0)
|
||||||
{
|
{
|
||||||
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command));
|
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command));
|
||||||
@@ -853,11 +1011,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
public CreateJoinAttemptCommand Command { get; } = command;
|
public CreateJoinAttemptCommand Command { get; } = command;
|
||||||
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
|
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
|
||||||
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
|
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
|
||||||
|
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
|
||||||
public TimeSpan Deadline { get; } = deadline;
|
public TimeSpan Deadline { get; } = deadline;
|
||||||
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
|
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
|
||||||
|
public TimeSpan? TicketDeadline { get; set; }
|
||||||
|
public DateTimeOffset? TicketWallExpiresAt { get; set; }
|
||||||
public AttemptEndpointBinding? HostEndpoint { get; set; }
|
public AttemptEndpointBinding? HostEndpoint { get; set; }
|
||||||
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
||||||
public bool IntroductionConsumed { get; set; }
|
public bool IntroductionConsumed { get; set; }
|
||||||
|
public bool ConnectionTicketConsumed { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record IdempotencyEntry(
|
private sealed record IdempotencyEntry(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
internal static class StoreResultMapping
|
||||||
|
{
|
||||||
|
public static RendezvousErrorCode ToContractError(this StoreResultCode code) => code switch
|
||||||
|
{
|
||||||
|
StoreResultCode.Success => RendezvousErrorCode.None,
|
||||||
|
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
|
||||||
|
StoreResultCode.Expired => RendezvousErrorCode.Expired,
|
||||||
|
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
|
||||||
|
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
|
||||||
|
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
|
||||||
|
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
|
||||||
|
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
_ => RendezvousErrorCode.InternalError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
|
internal static class LiteNetNatRequestCodec
|
||||||
|
{
|
||||||
|
private const byte NatMessageProperty = 17;
|
||||||
|
private const int TypeIdentifierLength = 8;
|
||||||
|
private const int TokenLengthPrefix = NatPunchRequestTokenCodec.EncodedLength + 1;
|
||||||
|
// LiteNetLib 2.1.4's private NatIntroduceRequest type ID. The native socket
|
||||||
|
// integration test deliberately fails if a package upgrade changes this wire value.
|
||||||
|
private static ReadOnlySpan<byte> RequestTypeIdentifier =>
|
||||||
|
[0x88, 0xbe, 0x10, 0x26, 0xbf, 0xb1, 0x66, 0x9c];
|
||||||
|
|
||||||
|
public static bool TryDecode(
|
||||||
|
ReadOnlySpan<byte> datagram,
|
||||||
|
out IPEndPoint? claimedLocalEndpoint,
|
||||||
|
out string? token)
|
||||||
|
{
|
||||||
|
claimedLocalEndpoint = null;
|
||||||
|
token = null;
|
||||||
|
if (datagram.Length < 1 + TypeIdentifierLength + 1 + 4 + 2 + 2
|
||||||
|
+ NatPunchRequestTokenCodec.EncodedLength
|
||||||
|
|| datagram[0] != NatMessageProperty
|
||||||
|
|| !datagram.Slice(1, TypeIdentifierLength).SequenceEqual(RequestTypeIdentifier))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int offset = 1 + TypeIdentifierLength;
|
||||||
|
int addressLength = datagram[offset++] switch
|
||||||
|
{
|
||||||
|
0 => 4,
|
||||||
|
1 => 16,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
int expectedLength = offset + addressLength + 2 + 2
|
||||||
|
+ NatPunchRequestTokenCodec.EncodedLength;
|
||||||
|
if (addressLength == 0 || datagram.Length != expectedLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress localAddress = new(datagram.Slice(offset, addressLength));
|
||||||
|
offset += addressLength;
|
||||||
|
int localPort = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
|
||||||
|
offset += 2;
|
||||||
|
int encodedTokenLength = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
|
||||||
|
offset += 2;
|
||||||
|
if (localPort == 0 || encodedTokenLength != TokenLengthPrefix)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> tokenBytes = datagram.Slice(
|
||||||
|
offset,
|
||||||
|
NatPunchRequestTokenCodec.EncodedLength);
|
||||||
|
for (int index = 0; index < tokenBytes.Length; index++)
|
||||||
|
{
|
||||||
|
if (tokenBytes[index] > 0x7f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedLocalEndpoint = new(localAddress, localPort);
|
||||||
|
token = Encoding.ASCII.GetString(tokenBytes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
|
internal interface INatIntroductionSink
|
||||||
|
{
|
||||||
|
void Introduce(NatIntroductionPlan plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record NatIntroductionPlan(
|
||||||
|
IPEndPoint HostLocal,
|
||||||
|
IPEndPoint HostPublic,
|
||||||
|
IPEndPoint ClientLocal,
|
||||||
|
IPEndPoint ClientPublic,
|
||||||
|
string ConnectionTicket)
|
||||||
|
{
|
||||||
|
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum NatMediationResult
|
||||||
|
{
|
||||||
|
Dropped = 0,
|
||||||
|
HostPresenceAccepted = 1,
|
||||||
|
HostPresenceRejected = 2,
|
||||||
|
WaitingForPeer = 3,
|
||||||
|
Introduced = 4,
|
||||||
|
Duplicate = 5,
|
||||||
|
Rejected = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class NatMediationProcessor(
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
JoinAttemptService joinAttempts)
|
||||||
|
{
|
||||||
|
public NatMediationResult ProcessDatagram(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||||
|
|| datagram is null
|
||||||
|
|| datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters
|
||||||
|
|| !IPAddress.TryParse(datagram.LocalAddress, out IPAddress? localAddress))
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPEndPoint claimedLocalEndpoint = new(localAddress, datagram.LocalPort);
|
||||||
|
NatPunchPeerRole role = datagram.MessageType == UdpPresenceMessageType.ClientPresence
|
||||||
|
? NatPunchPeerRole.Client
|
||||||
|
: NatPunchPeerRole.HostPresence;
|
||||||
|
bool observedIpv6 = observedPublicEndpoint.AddressFamily == AddressFamily.InterNetworkV6
|
||||||
|
&& !observedPublicEndpoint.Address.IsIPv4MappedToIPv6;
|
||||||
|
if (role == NatPunchPeerRole.Client && observedIpv6)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
NatMediationResult result = ProcessRequest(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
if (role != NatPunchPeerRole.HostPresence
|
||||||
|
|| result != NatMediationResult.HostPresenceRejected
|
||||||
|
|| observedIpv6)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProcessRequest(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
datagram.MediationHandle,
|
||||||
|
datagram.Capability),
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NatMediationResult ProcessRequest(
|
||||||
|
IPEndPoint claimedLocalEndpoint,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
string token,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(claimedLocalEndpoint);
|
||||||
|
ArgumentNullException.ThrowIfNull(observedPublicEndpoint);
|
||||||
|
ArgumentNullException.ThrowIfNull(introductionSink);
|
||||||
|
|
||||||
|
if (!NatPunchRequestTokenCodec.TryDecode(token, out NatPunchRequestToken? request)
|
||||||
|
|| request is null
|
||||||
|
|| !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint)
|
||||||
|
|| !capabilities.TryFingerprint(request.Capability, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
ObservedEndpoint? localEndpoint = TryCreatePrivateCandidate(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
publicEndpoint.AddressFamily,
|
||||||
|
out ObservedEndpoint candidate)
|
||||||
|
? candidate
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (request.Role == NatPunchPeerRole.HostPresence)
|
||||||
|
{
|
||||||
|
StoreResult<StoredListing> presence = store.BindHostPresence(new(
|
||||||
|
request.MediationHandle,
|
||||||
|
fingerprint,
|
||||||
|
publicEndpoint,
|
||||||
|
localEndpoint), cancellationToken);
|
||||||
|
return presence.Succeeded
|
||||||
|
? NatMediationResult.HostPresenceAccepted
|
||||||
|
: NatMediationResult.HostPresenceRejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
AttemptPeerRole role = request.Role switch
|
||||||
|
{
|
||||||
|
NatPunchPeerRole.Host => AttemptPeerRole.Host,
|
||||||
|
NatPunchPeerRole.Client => AttemptPeerRole.Client,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
if (role == default)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredJoinAttempt> bound = store.BindAttemptEndpoint(new(
|
||||||
|
request.MediationHandle,
|
||||||
|
role,
|
||||||
|
fingerprint,
|
||||||
|
publicEndpoint,
|
||||||
|
localEndpoint), cancellationToken);
|
||||||
|
if (!bound.Succeeded || bound.Value is null)
|
||||||
|
{
|
||||||
|
return bound.Code == StoreResultCode.ReplayRejected
|
||||||
|
? NatMediationResult.Rejected
|
||||||
|
: NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredJoinAttempt attempt = bound.Value;
|
||||||
|
if (attempt.IntroductionConsumed)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Duplicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.HostEndpoint is null || attempt.ClientEndpoint is null)
|
||||||
|
{
|
||||||
|
return NatMediationResult.WaitingForPeer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.HostEndpoint.PublicEndpoint.AddressFamily
|
||||||
|
!= attempt.ClientEndpoint.PublicEndpoint.AddressFamily)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<IntroductionEndpoints> consumed = store.ConsumeIntroduction(
|
||||||
|
request.MediationHandle,
|
||||||
|
cancellationToken);
|
||||||
|
if (!consumed.Succeeded || consumed.Value is null)
|
||||||
|
{
|
||||||
|
return consumed.Code == StoreResultCode.ReplayRejected
|
||||||
|
? NatMediationResult.Duplicate
|
||||||
|
: NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<ConnectionTicketGrant> ticket = joinAttempts.IssueConnectionTicket(
|
||||||
|
consumed.Value.Attempt);
|
||||||
|
if (!ticket.Succeeded || ticket.Value is null)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value.Ticket));
|
||||||
|
return NatMediationResult.Introduced;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SocketException
|
||||||
|
or InvalidOperationException
|
||||||
|
or ArgumentException)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NatIntroductionPlan CreatePlan(
|
||||||
|
IntroductionEndpoints endpoints,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
|
||||||
|
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
|
||||||
|
bool sameNat = hostPublic.Address.Equals(clientPublic.Address);
|
||||||
|
IPEndPoint hostLocal = sameNat && endpoints.Host.LocalEndpoint is { } hostCandidate
|
||||||
|
? ToIpEndpoint(hostCandidate)
|
||||||
|
: hostPublic;
|
||||||
|
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
|
||||||
|
? ToIpEndpoint(clientCandidate)
|
||||||
|
: clientPublic;
|
||||||
|
return new(hostLocal, hostPublic, clientLocal, clientPublic, connectionTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreateObservedEndpoint(
|
||||||
|
IPEndPoint source,
|
||||||
|
out ObservedEndpoint endpoint)
|
||||||
|
{
|
||||||
|
endpoint = default;
|
||||||
|
if (source.Port is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress address = source.Address.IsIPv4MappedToIPv6
|
||||||
|
? source.Address.MapToIPv4()
|
||||||
|
: source.Address;
|
||||||
|
if (address.Equals(IPAddress.Any)
|
||||||
|
|| address.Equals(IPAddress.IPv6Any)
|
||||||
|
|| address.IsIPv6Multicast
|
||||||
|
|| IsIpv4MulticastOrBroadcast(address)
|
||||||
|
|| (address.AddressFamily == AddressFamily.InterNetworkV6
|
||||||
|
&& !IsGlobalIpv6(address)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressFamilyKind family = address.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||||
|
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
if (family == default)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = new(family, address.ToString(), source.Port);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreatePrivateCandidate(
|
||||||
|
IPEndPoint source,
|
||||||
|
AddressFamilyKind publicFamily,
|
||||||
|
out ObservedEndpoint endpoint)
|
||||||
|
{
|
||||||
|
endpoint = default;
|
||||||
|
if (source.Port is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress address = source.Address.IsIPv4MappedToIPv6
|
||||||
|
? source.Address.MapToIPv4()
|
||||||
|
: source.Address;
|
||||||
|
AddressFamilyKind family = address.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||||
|
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
if (family != publicFamily || !IsPrivateUnicast(address))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = new(family, address.ToString(), source.Port);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPrivateUnicast(IPAddress address)
|
||||||
|
{
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return address.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => bytes[0] == 10
|
||||||
|
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||||
|
|| (bytes[0] == 192 && bytes[1] == 168),
|
||||||
|
AddressFamily.InterNetworkV6 => (bytes[0] & 0xfe) == 0xfc,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGlobalIpv6(IPAddress address) =>
|
||||||
|
!address.Equals(IPAddress.IPv6Loopback)
|
||||||
|
&& !address.Equals(IPAddress.IPv6Any)
|
||||||
|
&& !address.IsIPv6LinkLocal
|
||||||
|
&& !address.IsIPv6Multicast
|
||||||
|
&& !address.IsIPv6SiteLocal
|
||||||
|
&& !IsPrivateUnicast(address)
|
||||||
|
&& !IsDocumentationIpv6(address);
|
||||||
|
|
||||||
|
private static bool IsIpv4MulticastOrBroadcast(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address.AddressFamily != AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return bytes[0] >= 224 || bytes.All(static value => value == byte.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDocumentationIpv6(IPAddress address)
|
||||||
|
{
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPEndPoint ToIpEndpoint(ObservedEndpoint endpoint) =>
|
||||||
|
new(IPAddress.Parse(endpoint.Address), endpoint.Port);
|
||||||
|
}
|
||||||
@@ -18,9 +18,17 @@ public sealed class UdpMediatorOptions
|
|||||||
[Required]
|
[Required]
|
||||||
public string ListenAddress { get; set; } = "0.0.0.0";
|
public string ListenAddress { get; set; } = "0.0.0.0";
|
||||||
|
|
||||||
|
public string? Ipv6ListenAddress { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
|
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Range(0, 65_535)]
|
[Range(0, 65_535)]
|
||||||
public int Port { get; set; } = 9050;
|
public int Port { get; set; } = 9050;
|
||||||
|
|
||||||
|
[Range(1, 4_096)]
|
||||||
|
public int MaxDatagramsPerPoll { get; set; } = 256;
|
||||||
|
|
||||||
|
[Range(1, 100)]
|
||||||
|
public int PollIntervalMilliseconds { get; set; } = 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +1,136 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
using FinalFactory.Rendezvous.Server.Sessions;
|
using LiteNetLib;
|
||||||
using FinalFactory.Rendezvous.Server.State;
|
using LiteNetLib.Layers;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Owns the cancellable UDP socket used by the future NAT mediator.
|
|
||||||
/// </summary>
|
|
||||||
internal 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 NatMediationProcessor _processor;
|
||||||
private readonly ISessionCapabilityService _capabilities;
|
private LiteNetManager? _manager;
|
||||||
private UdpClient? _udpClient;
|
private LiteNetIntroductionSink? _introductionSink;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new UDP mediator service.
|
|
||||||
/// </summary>
|
|
||||||
public UdpMediatorService(
|
public UdpMediatorService(
|
||||||
IOptions<UdpMediatorOptions> options,
|
IOptions<UdpMediatorOptions> options,
|
||||||
ILogger<UdpMediatorService> logger,
|
ILogger<UdpMediatorService> logger,
|
||||||
IEphemeralRendezvousStore store,
|
NatMediationProcessor processor)
|
||||||
ISessionCapabilityService capabilities)
|
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_store = store;
|
_processor = processor;
|
||||||
_capabilities = capabilities;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the bound endpoint after startup completes.
|
|
||||||
/// </summary>
|
|
||||||
public IPEndPoint? LocalEndpoint { get; private set; }
|
public IPEndPoint? LocalEndpoint { get; private set; }
|
||||||
|
public IPEndPoint? LocalIpv6Endpoint { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override Task StartAsync(CancellationToken cancellationToken)
|
public override Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (_manager is not null)
|
||||||
if (_udpClient is not null)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The UDP mediator is already running.");
|
throw new InvalidOperationException("The UDP mediator is already running.");
|
||||||
}
|
}
|
||||||
|
|
||||||
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
|
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
|
||||||
UdpClient udpClient = new(new IPEndPoint(listenAddress, _options.Port));
|
if (listenAddress.AddressFamily != AddressFamily.InterNetwork)
|
||||||
_udpClient = udpClient;
|
{
|
||||||
IPEndPoint localEndpoint =
|
throw new InvalidOperationException("The required UDP listen address must be IPv4.");
|
||||||
(IPEndPoint?)udpClient.Client.LocalEndPoint
|
}
|
||||||
?? throw new InvalidOperationException("The UDP socket did not expose its bound endpoint.");
|
|
||||||
LocalEndpoint = localEndpoint;
|
|
||||||
|
|
||||||
LogMediatorListening(_logger, localEndpoint.Address, localEndpoint.Port);
|
IPAddress? ipv6ListenAddress = string.IsNullOrWhiteSpace(_options.Ipv6ListenAddress)
|
||||||
|
? null
|
||||||
|
: IPAddress.Parse(_options.Ipv6ListenAddress);
|
||||||
|
if (ipv6ListenAddress is not null
|
||||||
|
&& ipv6ListenAddress.AddressFamily != AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The optional UDP IPv6 listen address must be IPv6.");
|
||||||
|
}
|
||||||
|
|
||||||
|
EventBasedLiteNetListener listener = new();
|
||||||
|
RendezvousPacketLayer packetLayer = new(_processor);
|
||||||
|
LiteNetManager manager = new(listener, packetLayer)
|
||||||
|
{
|
||||||
|
NatPunchEnabled = true,
|
||||||
|
IPv6Enabled = ipv6ListenAddress is not null,
|
||||||
|
UnsyncedEvents = true,
|
||||||
|
MaxPacketPerManualReceive = _options.MaxDatagramsPerPoll,
|
||||||
|
};
|
||||||
|
manager.NatPunchModule.UnsyncedEvents = true;
|
||||||
|
_introductionSink = new(manager.NatPunchModule);
|
||||||
|
packetLayer.Attach(_introductionSink);
|
||||||
|
|
||||||
|
if (!manager.StartInManualMode(
|
||||||
|
listenAddress,
|
||||||
|
ipv6ListenAddress ?? IPAddress.IPv6Any,
|
||||||
|
_options.Port))
|
||||||
|
{
|
||||||
|
_introductionSink = null;
|
||||||
|
manager.Stop();
|
||||||
|
throw new InvalidOperationException("The UDP mediator could not bind its LiteNetLib socket.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager = manager;
|
||||||
|
LocalEndpoint = new(listenAddress, manager.LocalPort);
|
||||||
|
LocalIpv6Endpoint = ipv6ListenAddress is null
|
||||||
|
? null
|
||||||
|
: new(ipv6ListenAddress, manager.LocalPort);
|
||||||
|
LogMediatorListening(_logger, listenAddress, manager.LocalPort);
|
||||||
return base.StartAsync(cancellationToken);
|
return base.StartAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_udpClient?.Dispose();
|
StopManager();
|
||||||
_udpClient = null;
|
|
||||||
LocalEndpoint = null;
|
|
||||||
LogMediatorStopped(_logger);
|
LogMediatorStopped(_logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
_udpClient?.Dispose();
|
StopManager();
|
||||||
_udpClient = null;
|
|
||||||
LocalEndpoint = null;
|
|
||||||
base.Dispose();
|
base.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
UdpClient udpClient = _udpClient
|
LiteNetManager manager = _manager
|
||||||
?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
|
?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
|
||||||
|
long previous = Stopwatch.GetTimestamp();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
UdpReceiveResult received = await udpClient
|
manager.PollEvents();
|
||||||
.ReceiveAsync(stoppingToken)
|
manager.NatPunchModule.PollEvents();
|
||||||
.ConfigureAwait(false);
|
long current = Stopwatch.GetTimestamp();
|
||||||
ProcessDatagram(received.Buffer, received.RemoteEndPoint, stoppingToken);
|
manager.ManualUpdate((float)Stopwatch.GetElapsedTime(previous, current).TotalMilliseconds);
|
||||||
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
|
previous = current;
|
||||||
|
await Task.Delay(_options.PollIntervalMilliseconds, stoppingToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Expected during normal shutdown.
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// Disposing the socket is the fallback that releases a blocked receive.
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
LocalEndpoint = null;
|
LocalEndpoint = null;
|
||||||
|
LocalIpv6Endpoint = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal UdpPresenceProcessingResult ProcessDatagram(
|
private void StopManager()
|
||||||
ReadOnlySpan<byte> encoded,
|
|
||||||
IPEndPoint observedSource,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(observedSource);
|
LiteNetManager? manager = Interlocked.Exchange(ref _manager, null);
|
||||||
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
_introductionSink = null;
|
||||||
|| datagram is null
|
LocalEndpoint = null;
|
||||||
|| !_capabilities.TryFingerprint(datagram.Capability, out SecretFingerprint fingerprint))
|
LocalIpv6Endpoint = null;
|
||||||
{
|
manager?.Stop();
|
||||||
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(
|
||||||
@@ -172,12 +147,62 @@ internal sealed partial class UdpMediatorService : BackgroundService
|
|||||||
Level = LogLevel.Information,
|
Level = LogLevel.Information,
|
||||||
Message = "UDP mediator stopped")]
|
Message = "UDP mediator stopped")]
|
||||||
private static partial void LogMediatorStopped(ILogger logger);
|
private static partial void LogMediatorStopped(ILogger logger);
|
||||||
}
|
|
||||||
|
|
||||||
internal enum UdpPresenceProcessingResult
|
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
|
||||||
{
|
{
|
||||||
Dropped = 0,
|
public void Introduce(NatIntroductionPlan plan) => module.NatIntroduce(
|
||||||
HostPresenceAccepted = 1,
|
plan.HostLocal,
|
||||||
HostPresenceRejected = 2,
|
plan.HostPublic,
|
||||||
ClientPresenceDeferred = 3,
|
plan.ClientLocal,
|
||||||
|
plan.ClientPublic,
|
||||||
|
plan.ConnectionTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
|
||||||
|
{
|
||||||
|
private INatIntroductionSink? _sink;
|
||||||
|
|
||||||
|
public void Attach(INatIntroductionSink sink) => _sink = sink;
|
||||||
|
|
||||||
|
public override void ProcessInboundPacket(
|
||||||
|
ref IPEndPoint endPoint,
|
||||||
|
ref byte[] data,
|
||||||
|
ref int length)
|
||||||
|
{
|
||||||
|
bool isFrozenEnvelope = length >= 2
|
||||||
|
&& data[0] == RendezvousUdpCodec.MagicFirst
|
||||||
|
&& data[1] == RendezvousUdpCodec.MagicSecond;
|
||||||
|
INatIntroductionSink? sink = _sink;
|
||||||
|
if (isFrozenEnvelope)
|
||||||
|
{
|
||||||
|
if (sink is not null)
|
||||||
|
{
|
||||||
|
_ = processor.ProcessDatagram(data.AsSpan(0, length), endPoint, sink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (sink is not null
|
||||||
|
&& LiteNetNatRequestCodec.TryDecode(
|
||||||
|
data.AsSpan(0, length),
|
||||||
|
out IPEndPoint? claimedLocalEndpoint,
|
||||||
|
out string? token)
|
||||||
|
&& claimedLocalEndpoint is not null
|
||||||
|
&& token is not null)
|
||||||
|
{
|
||||||
|
_ = processor.ProcessRequest(claimedLocalEndpoint, endPoint, token, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
|
||||||
|
Drop(ref length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void ProcessOutBoundPacket(
|
||||||
|
ref IPEndPoint endPoint,
|
||||||
|
ref byte[] data,
|
||||||
|
ref int offset,
|
||||||
|
ref int length)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Drop(ref int length) => length = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
"Rendezvous": {
|
"Rendezvous": {
|
||||||
"Udp": {
|
"Udp": {
|
||||||
"ListenAddress": "0.0.0.0",
|
"ListenAddress": "0.0.0.0",
|
||||||
"Port": 9050
|
"Port": 9050,
|
||||||
|
"MaxDatagramsPerPoll": 256,
|
||||||
|
"PollIntervalMilliseconds": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class ConnectionTicketValidatorTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuthorizedTicketIsAcceptedExactlyOnce()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||||
|
JoinAttemptId attempt = NewAttempt();
|
||||||
|
string ticket = Ticket('A');
|
||||||
|
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||||
|
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Accepted,
|
||||||
|
validator.Consume(attempt, ticket));
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.AlreadyConsumed,
|
||||||
|
validator.Consume(attempt, ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlteredCrossAttemptExpiredAndRevokedTicketsAreRejected()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||||
|
JoinAttemptId first = NewAttempt();
|
||||||
|
JoinAttemptId second = NewAttempt();
|
||||||
|
Assert.True(validator.TryAuthorize(first, Ticket('A'), Now.AddSeconds(20)));
|
||||||
|
Assert.True(validator.TryAuthorize(second, Ticket('B'), Now.AddSeconds(40)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Rejected,
|
||||||
|
validator.Consume(first, Ticket('B')));
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(20));
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Expired,
|
||||||
|
validator.Consume(first, Ticket('A')));
|
||||||
|
Assert.True(validator.Revoke(second));
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Revoked,
|
||||||
|
validator.Consume(second, Ticket('B')));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentConsumptionHasOneWinner()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||||
|
JoinAttemptId attempt = NewAttempt();
|
||||||
|
string ticket = Ticket('C');
|
||||||
|
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||||
|
using ManualResetEventSlim start = new(false);
|
||||||
|
Task<ConnectionTicketConsumptionResult> left = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return validator.Consume(attempt, ticket);
|
||||||
|
});
|
||||||
|
Task<ConnectionTicketConsumptionResult> right = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return validator.Consume(attempt, ticket);
|
||||||
|
});
|
||||||
|
|
||||||
|
start.Set();
|
||||||
|
ConnectionTicketConsumptionResult[] results = await Task.WhenAll(left, right);
|
||||||
|
|
||||||
|
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.Accepted);
|
||||||
|
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.AlreadyConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidatorIsBoundedDisposableAndRedacted()
|
||||||
|
{
|
||||||
|
ManualConnectionTicketClock clock = new();
|
||||||
|
ConnectionTicketValidator validator = new(1, clock);
|
||||||
|
Assert.True(validator.TryAuthorize(NewAttempt(), Ticket('A'), Now.AddSeconds(20)));
|
||||||
|
Assert.False(validator.TryAuthorize(NewAttempt(), Ticket('B'), Now.AddSeconds(20)));
|
||||||
|
Assert.DoesNotContain(Ticket('A'), validator.ToString(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
validator.Dispose();
|
||||||
|
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => validator.Revoke(NewAttempt()));
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => validator.Consume(default, string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JoinAttemptId NewAttempt() => new(Guid.NewGuid());
|
||||||
|
private static string Ticket(char value) => new(value, 43);
|
||||||
|
|
||||||
|
private sealed class ManualConnectionTicketClock : IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; set; } = Now;
|
||||||
|
|
||||||
|
public void Advance(TimeSpan duration) => UtcNow += duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||||
|
|
||||||
|
public sealed class NatPunchRequestTokenCodecTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NatPunchPeerRole.HostPresence)]
|
||||||
|
[InlineData(NatPunchPeerRole.Host)]
|
||||||
|
[InlineData(NatPunchPeerRole.Client)]
|
||||||
|
public void FixedSizeTokensRoundTripBelowLiteNetLibLimit(NatPunchPeerRole role)
|
||||||
|
{
|
||||||
|
MediationHandle handle = new(Guid.NewGuid());
|
||||||
|
const string capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||||
|
|
||||||
|
string encoded = NatPunchRequestTokenCodec.Encode(role, handle, capability);
|
||||||
|
|
||||||
|
Assert.Equal(NatPunchRequestTokenCodec.EncodedLength, encoded.Length);
|
||||||
|
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||||
|
Assert.True(NatPunchRequestTokenCodec.TryDecode(encoded, out NatPunchRequestToken? decoded));
|
||||||
|
Assert.NotNull(decoded);
|
||||||
|
Assert.Equal(role, decoded.Role);
|
||||||
|
Assert.Equal(handle, decoded.MediationHandle);
|
||||||
|
Assert.Equal(capability, decoded.Capability);
|
||||||
|
Assert.DoesNotContain(capability, decoded.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MalformedAndNonCanonicalTokensAreRejected()
|
||||||
|
{
|
||||||
|
string valid = NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Client,
|
||||||
|
new MediationHandle(Guid.Parse("00112233-4455-6677-8899-aabbccddeeff")),
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||||
|
string uppercaseHandle = valid[..6]
|
||||||
|
+ valid.Substring(6, 32).ToUpperInvariant()
|
||||||
|
+ valid[38..];
|
||||||
|
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode(null, out _));
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1], out _));
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode("x" + valid[1..], out _));
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1] + "x", out _));
|
||||||
|
Assert.False(NatPunchRequestTokenCodec.TryDecode(uppercaseHandle, out _));
|
||||||
|
Assert.Throws<ArgumentException>(() => NatPunchRequestTokenCodec.Encode(
|
||||||
|
(NatPunchPeerRole)99,
|
||||||
|
new MediationHandle(Guid.NewGuid()),
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedWireLengthHasADedicatedLiteNetSafeContractLimit()
|
||||||
|
{
|
||||||
|
Assert.Equal(192, ContractLimits.NatPunchRequestTokenCharacters);
|
||||||
|
Assert.Equal(
|
||||||
|
ContractLimits.NatPunchRequestTokenCharacters,
|
||||||
|
NatPunchRequestTokenCodec.EncodedLength);
|
||||||
|
Assert.True(
|
||||||
|
ContractLimits.NatPunchRequestTokenCharacters
|
||||||
|
<= ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
"/health/live",
|
"/health/live",
|
||||||
"/health/ready",
|
"/health/ready",
|
||||||
"/v1/join-attempts",
|
"/v1/join-attempts",
|
||||||
|
"/v1/join-attempts/{attemptId}",
|
||||||
"/v1/join-attempts/{attemptId}/outcome",
|
"/v1/join-attempts/{attemptId}/outcome",
|
||||||
"/v1/sessions",
|
"/v1/sessions",
|
||||||
"/v1/sessions/{listingId}",
|
"/v1/sessions/{listingId}",
|
||||||
@@ -85,5 +86,23 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
.GetProperty("security");
|
.GetProperty("security");
|
||||||
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
|
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JsonElement cancelParameters = root.GetProperty("paths")
|
||||||
|
.GetProperty("/v1/join-attempts/{attemptId}")
|
||||||
|
.GetProperty("delete")
|
||||||
|
.GetProperty("parameters");
|
||||||
|
JsonElement cancelCapability = Assert.Single(cancelParameters.EnumerateArray(), static parameter =>
|
||||||
|
parameter.GetProperty("in").GetString() == "header"
|
||||||
|
&& parameter.GetProperty("name").GetString()
|
||||||
|
== "X-Rendezvous-Client-Punch-Capability");
|
||||||
|
Assert.True(cancelCapability.GetProperty("required").GetBoolean());
|
||||||
|
JsonElement hostPollParameters = root.GetProperty("paths")
|
||||||
|
.GetProperty("/v1/sessions/{listingId}/join-attempts")
|
||||||
|
.GetProperty("get")
|
||||||
|
.GetProperty("parameters");
|
||||||
|
JsonElement leaseToken = Assert.Single(hostPollParameters.EnumerateArray(), static parameter =>
|
||||||
|
parameter.GetProperty("in").GetString() == "header"
|
||||||
|
&& parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token");
|
||||||
|
Assert.True(leaseToken.GetProperty("required").GetBoolean());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
public sealed class JoinAttemptHttpEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientCreatesHostPollsAndCapabilityCancelsAnAttemptOverHttp()
|
||||||
|
{
|
||||||
|
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||||
|
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||||
|
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(),
|
||||||
|
host.PublisherCredential));
|
||||||
|
Assert.True(host.Capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint presenceFingerprint));
|
||||||
|
Assert.True(host.Store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
presenceFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
CreateJoinAttemptRequest request = new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "http-join-1",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ListingId = session.ListingId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
|
||||||
|
"v1/join-attempts",
|
||||||
|
request,
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
|
||||||
|
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
|
||||||
|
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
|
||||||
|
|
||||||
|
using HttpRequestMessage pollRequest = new(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||||
|
pollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||||
|
using HttpResponseMessage pollResponse = await host.HttpClient.SendAsync(pollRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, pollResponse.StatusCode);
|
||||||
|
BrowseHostJoinAttemptsResponse polled = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||||
|
await pollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||||
|
HostJoinAttempt hostAttempt = Assert.Single(polled.Items);
|
||||||
|
Assert.Equal(created.AttemptId, hostAttempt.AttemptId);
|
||||||
|
Assert.NotEqual(created.ClientPunchCapability, hostAttempt.HostPunchCapability);
|
||||||
|
|
||||||
|
using HttpResponseMessage missingCapability = await host.HttpClient.DeleteAsync(
|
||||||
|
$"v1/join-attempts/{created.AttemptId}");
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, missingCapability.StatusCode);
|
||||||
|
ApiError missingCapabilityError = Assert.IsType<ApiError>(
|
||||||
|
await missingCapability.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
|
||||||
|
Assert.Equal(RendezvousErrorCode.InvalidRequest, missingCapabilityError.Code);
|
||||||
|
|
||||||
|
using HttpRequestMessage unauthorizedCancel = new(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/join-attempts/{created.AttemptId}");
|
||||||
|
unauthorizedCancel.Headers.Add(
|
||||||
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||||
|
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedCancel);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
|
||||||
|
|
||||||
|
using HttpRequestMessage cancelRequest = new(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/join-attempts/{created.AttemptId}");
|
||||||
|
cancelRequest.Headers.Add(
|
||||||
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
|
created.ClientPunchCapability);
|
||||||
|
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
|
||||||
|
|
||||||
|
using HttpRequestMessage emptyPollRequest = new(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||||
|
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||||
|
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest);
|
||||||
|
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||||
|
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||||
|
Assert.Empty(empty.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
return Assert.IsAssignableFrom<T>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CreateRegistration() => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "join-http-host",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "Join HTTP host",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
|
||||||
|
Metadata = new() { ["mode"] = "online-coop" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class JoinHttpTestHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _application;
|
||||||
|
|
||||||
|
private JoinHttpTestHost(
|
||||||
|
WebApplication application,
|
||||||
|
HttpClient httpClient,
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
EphemeralCapabilityIssuer capabilities,
|
||||||
|
string publisherCredential)
|
||||||
|
{
|
||||||
|
_application = application;
|
||||||
|
HttpClient = httpClient;
|
||||||
|
Store = store;
|
||||||
|
Capabilities = capabilities;
|
||||||
|
PublisherCredential = publisherCredential;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HttpClient HttpClient { get; }
|
||||||
|
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||||
|
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||||
|
internal string PublisherCredential { get; }
|
||||||
|
|
||||||
|
internal static async Task<JoinHttpTestHost> StartAsync()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
|
EphemeralStoreOptions stateOptions = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||||
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
|
ProvisioningTestData.CreateOptions(),
|
||||||
|
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||||
|
clock.UtcNow);
|
||||||
|
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||||
|
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||||
|
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
|
options.ThrowOnBadRequest = true);
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||||
|
builder.Services.AddSingleton(provisioning);
|
||||||
|
builder.Services.AddSingleton(provisioning.Policies);
|
||||||
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(clock);
|
||||||
|
builder.Services.AddSingleton(capabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||||
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.MapRendezvousContractEndpoints();
|
||||||
|
await app.StartAsync();
|
||||||
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
|
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
|
return new(
|
||||||
|
app,
|
||||||
|
new HttpClient { BaseAddress = new Uri(address) },
|
||||||
|
store,
|
||||||
|
capabilities,
|
||||||
|
credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
HttpClient.Dispose();
|
||||||
|
await _application.StopAsync();
|
||||||
|
await _application.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
public sealed class JoinAttemptServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreateIsIdempotentAndScopesDistinctRoleCredentials()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "stable-join-key");
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> first = fixture.Service.Create(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
request);
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> replay = fixture.Service.Create(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
request);
|
||||||
|
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.True(replay.Succeeded);
|
||||||
|
Assert.Equal(first.Value!.AttemptId, replay.Value!.AttemptId);
|
||||||
|
Assert.Equal(first.Value.MediationHandle, replay.Value.MediationHandle);
|
||||||
|
Assert.Equal(first.Value.ClientPunchCapability, replay.Value.ClientPunchCapability);
|
||||||
|
Assert.True(ContractValidation.IsCapabilityValid(first.Value.ClientPunchCapability));
|
||||||
|
Assert.InRange(
|
||||||
|
first.Value.ClientPunchCapability.Length,
|
||||||
|
1,
|
||||||
|
ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||||
|
|
||||||
|
HostJoinAttempt host = Assert.Single(fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
10,
|
||||||
|
null).Value!.Items);
|
||||||
|
Assert.NotEqual(host.HostPunchCapability, first.Value.ClientPunchCapability);
|
||||||
|
Assert.DoesNotContain(first.Value.ClientPunchCapability, fixture.Sessions.Store.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameIdempotencyKeyWithDifferentRequestConflicts()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "reused-key");
|
||||||
|
Assert.True(fixture.Service.Create(fixture.ClientSubject, request).Succeeded);
|
||||||
|
|
||||||
|
request.ListingId = other.ListingId;
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> conflict = fixture.Service.Create(
|
||||||
|
fixture.ClientSubject,
|
||||||
|
request);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousErrorCode.Conflict, conflict.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreationRejectsStaleIncompatibleAndCrossTenantListings()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
|
||||||
|
|
||||||
|
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptRequest incompatible = fixture.Request(active.ListingId);
|
||||||
|
incompatible.ProtocolVersion = 8;
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, incompatible).Error);
|
||||||
|
|
||||||
|
CreateJoinAttemptRequest otherTenant = fixture.Request(active.ListingId);
|
||||||
|
otherTenant.GameId = new("other-game");
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
fixture.Create(registration.ListingId);
|
||||||
|
fixture.Create(registration.ListingId);
|
||||||
|
fixture.Create(registration.ListingId);
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> first = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
1,
|
||||||
|
null);
|
||||||
|
Assert.True(first.Succeeded);
|
||||||
|
Assert.Single(first.Value!.Items);
|
||||||
|
Assert.NotNull(first.Value.NextCursor);
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> second = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
1,
|
||||||
|
first.Value.NextCursor);
|
||||||
|
Assert.True(second.Succeeded);
|
||||||
|
Assert.NotEqual(first.Value.Items[0].AttemptId, second.Value!.Items[0].AttemptId);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
1,
|
||||||
|
null).Error);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.InvalidRequest,
|
||||||
|
fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
1,
|
||||||
|
first.Value.NextCursor + "x").Error);
|
||||||
|
|
||||||
|
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.InvalidRequest,
|
||||||
|
fixture.Service.BrowseForHost(
|
||||||
|
other.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
other.LeaseToken,
|
||||||
|
1,
|
||||||
|
first.Value.NextCursor).Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationRequiresTheAttemptsClientCapabilityAndRevokesState()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Error);
|
||||||
|
Assert.True(fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
created.ClientPunchCapability).Succeeded);
|
||||||
|
Assert.Empty(fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
10,
|
||||||
|
null).Value!.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoleAndAttemptCapabilitiesCannotCrossWireConcurrentAttempts()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
||||||
|
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
||||||
|
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
second.ClientPunchCapability,
|
||||||
|
out SecretFingerprint secondClientFingerprint));
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
first.ClientPunchCapability,
|
||||||
|
out SecretFingerprint firstClientFingerprint));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
firstStored.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
secondClientFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "198.51.100.20", 42_000),
|
||||||
|
null)).Code);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
firstStored.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
firstClientFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
|
||||||
|
null)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectionTicketIsDistinctExpiringAndAtomicallySingleUse()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
JoinAttemptServiceResult<ConnectionTicketGrant> issued = fixture.Service.IssueConnectionTicket(
|
||||||
|
introduction.Attempt);
|
||||||
|
Assert.True(issued.Succeeded);
|
||||||
|
Assert.True(ContractValidation.IsConnectionTicketValid(issued.Value!.Ticket));
|
||||||
|
Assert.NotEqual(created.ClientPunchCapability, issued.Value.Ticket);
|
||||||
|
Assert.DoesNotContain(issued.Value.Ticket, issued.Value.ToString(), StringComparison.Ordinal);
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
issued.Value.Ticket,
|
||||||
|
out SecretFingerprint ticketFingerprint));
|
||||||
|
ConsumeConnectionTicketCommand command = new(created.AttemptId, ticketFingerprint);
|
||||||
|
using ManualResetEventSlim start = new(false);
|
||||||
|
|
||||||
|
Task<StoreResult<bool>> left = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
||||||
|
});
|
||||||
|
Task<StoreResult<bool>> right = Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.Wait();
|
||||||
|
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
||||||
|
});
|
||||||
|
start.Set();
|
||||||
|
StoreResult<bool>[] results = await Task.WhenAll(left, right);
|
||||||
|
|
||||||
|
Assert.Single(results, static result => result.Succeeded);
|
||||||
|
Assert.Single(results, static result => result.Code == StoreResultCode.ReplayRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TicketRejectsAlteredCrossAttemptPreIntroductionAndExpiry()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions options = new()
|
||||||
|
{
|
||||||
|
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
};
|
||||||
|
using JoinAttemptFixture fixture = new(options);
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
||||||
|
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
||||||
|
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
||||||
|
StoredJoinAttempt secondStored = fixture.GetAttempt(registration, second.AttemptId);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.Conflict,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
first.AttemptId,
|
||||||
|
firstStored.ConnectionTicketFingerprint)).Code);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.NotFound,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
second.AttemptId,
|
||||||
|
firstStored.ConnectionTicketFingerprint)).Code);
|
||||||
|
|
||||||
|
fixture.Introduce(registration, first);
|
||||||
|
fixture.Sessions.Clock.Advance(options.ConnectionTicketLifetime);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.Expired,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
first.AttemptId,
|
||||||
|
firstStored.ConnectionTicketFingerprint)).Code);
|
||||||
|
Assert.False(secondStored.ConnectionTicketConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TicketWindowBeginsAtIntroductionAndNeverOutlivesTheAttempt()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
ConnectionTicketGrant ticket = Assert.IsType<ConnectionTicketGrant>(
|
||||||
|
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
|
||||||
|
|
||||||
|
Assert.Equal(created.ExpiresAt, ticket.ExpiresAt);
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(15), ticket.ExpiresAt - fixture.Sessions.Clock.UtcNow);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
introduction.Attempt.CapabilityDerivationSalt,
|
||||||
|
introduction.Attempt.ToString(),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Sessions;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptFixture : IDisposable
|
||||||
|
{
|
||||||
|
private int _sequence;
|
||||||
|
|
||||||
|
public JoinAttemptFixture(EphemeralStoreOptions? options = null)
|
||||||
|
{
|
||||||
|
Sessions = new(options);
|
||||||
|
Cursors = new();
|
||||||
|
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
|
||||||
|
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
|
||||||
|
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionLeaseFixture Sessions { get; }
|
||||||
|
public JoinAttemptCursorCodec Cursors { get; }
|
||||||
|
public JoinAttemptService Service { get; }
|
||||||
|
public string ClientSubject { get; }
|
||||||
|
|
||||||
|
public (RegisterSessionResponse Registration, StoredListing Listing) CreateHost(bool bindPresence = true)
|
||||||
|
{
|
||||||
|
RegisterSessionResponse registration = Sessions.Register();
|
||||||
|
if (bindPresence)
|
||||||
|
{
|
||||||
|
Assert.True(Sessions.BindPresence(registration).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing listing = Sessions.Store.GetListing(registration.ListingId, false).Value!;
|
||||||
|
return (registration, listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreateJoinAttemptRequest Request(
|
||||||
|
SessionListingId listingId,
|
||||||
|
string? idempotencyKey = null) => new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = idempotencyKey ?? $"join-{Interlocked.Increment(ref _sequence)}",
|
||||||
|
GameId = Sessions.Scope.GameId,
|
||||||
|
EnvironmentId = Sessions.Scope.EnvironmentId,
|
||||||
|
ListingId = listingId,
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
public CreateJoinAttemptResponse Create(
|
||||||
|
SessionListingId listingId,
|
||||||
|
string? idempotencyKey = null)
|
||||||
|
{
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = Service.Create(
|
||||||
|
ClientSubject,
|
||||||
|
Request(listingId, idempotencyKey));
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
return Assert.IsType<CreateJoinAttemptResponse>(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredJoinAttempt GetAttempt(
|
||||||
|
RegisterSessionResponse registration,
|
||||||
|
JoinAttemptId attemptId)
|
||||||
|
{
|
||||||
|
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||||
|
registration.LeaseToken,
|
||||||
|
out SecretFingerprint leaseFingerprint));
|
||||||
|
IReadOnlyList<StoredJoinAttempt> attempts = Sessions.Store.BrowseHostJoinAttempts(new(
|
||||||
|
registration.ListingId,
|
||||||
|
leaseFingerprint,
|
||||||
|
ContractLimits.BrowserPageMaxItems)).Value!;
|
||||||
|
return attempts.Single(attempt => attempt.AttemptId == attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IntroductionEndpoints Introduce(
|
||||||
|
RegisterSessionResponse registration,
|
||||||
|
CreateJoinAttemptResponse created)
|
||||||
|
{
|
||||||
|
StoredJoinAttempt attempt = GetAttempt(registration, created.AttemptId);
|
||||||
|
HostJoinAttempt host = Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||||
|
host.HostPunchCapability,
|
||||||
|
out SecretFingerprint hostFingerprint));
|
||||||
|
Assert.True(Sessions.Capabilities.TryFingerprint(
|
||||||
|
created.ClientPunchCapability,
|
||||||
|
out SecretFingerprint clientFingerprint));
|
||||||
|
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
hostFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
clientFingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "198.51.100.40", 42_000),
|
||||||
|
null)).Succeeded);
|
||||||
|
StoreResult<IntroductionEndpoints> introduced = Sessions.Store.ConsumeIntroduction(
|
||||||
|
attempt.MediationHandle);
|
||||||
|
Assert.True(introduced.Succeeded);
|
||||||
|
return introduced.Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Cursors.Dispose();
|
||||||
|
Sessions.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,15 @@ namespace FinalFactory.Rendezvous.Tests.Provisioning;
|
|||||||
|
|
||||||
public sealed class PrincipalCredentialTests
|
public sealed class PrincipalCredentialTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Base64UrlDecoderRejectsNonCanonicalTrailingBits()
|
||||||
|
{
|
||||||
|
Assert.True(Base64Url.TryDecode("AA", out byte[] canonical));
|
||||||
|
Assert.Equal(new byte[] { 0 }, canonical);
|
||||||
|
Assert.False(Base64Url.TryDecode("AB", out byte[] nonCanonical));
|
||||||
|
Assert.Empty(nonCanonical);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
|
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Server;
|
||||||
|
|
||||||
|
public sealed class NatMediationProcessorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AuthenticatedHostPresenceUsesTheObservedGameplaySocket()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost(bindPresence: false);
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
string token = NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.HostPresence,
|
||||||
|
registration.HostPresenceHandle,
|
||||||
|
registration.HostPresenceCapability);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.HostPresenceAccepted,
|
||||||
|
processor.ProcessRequest(
|
||||||
|
Endpoint("192.168.1.50", 40_000),
|
||||||
|
Endpoint("203.0.113.77", 51_234),
|
||||||
|
token,
|
||||||
|
sink));
|
||||||
|
Assert.Equal(registration.ListingId, Assert.Single(fixture.Sessions.Browse()).Definition.ListingId);
|
||||||
|
Assert.Empty(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "same-nat");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.WaitingForPeer,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Introduced,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
|
||||||
|
|
||||||
|
NatIntroductionPlan plan = Assert.Single(sink.Plans);
|
||||||
|
Assert.Equal(Endpoint("192.168.1.10", 41_000), plan.HostLocal);
|
||||||
|
Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal);
|
||||||
|
Assert.Equal(Endpoint("203.0.113.20", 51_000), plan.HostPublic);
|
||||||
|
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
|
||||||
|
Assert.Equal(43, plan.ConnectionTicket.Length);
|
||||||
|
Assert.DoesNotContain(plan.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
plan.ConnectionTicket,
|
||||||
|
out SecretFingerprint ticketFingerprint));
|
||||||
|
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
attempt.AttemptId,
|
||||||
|
ticketFingerprint)).Succeeded);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Duplicate,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
|
||||||
|
Assert.Single(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "different-nats");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
_ = Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("8.8.8.8", 42_000), Endpoint("198.51.100.40", 52_000));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Introduced,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
|
||||||
|
NatIntroductionPlan plan = Assert.Single(sink.Plans);
|
||||||
|
Assert.Equal(plan.HostPublic, plan.HostLocal);
|
||||||
|
Assert.Equal(plan.ClientPublic, plan.ClientLocal);
|
||||||
|
Assert.NotEqual(IPAddress.Parse("8.8.8.8"), plan.ClientLocal.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoleAndEndpointSubstitutionAreRejectedWithoutChangingTheFirstBinding()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "substitution");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
string crossRole = NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
attempt.Handle,
|
||||||
|
attempt.ClientCapability);
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
processor.ProcessRequest(
|
||||||
|
Endpoint("192.168.1.10", 41_000),
|
||||||
|
Endpoint("203.0.113.20", 51_000),
|
||||||
|
crossRole,
|
||||||
|
sink));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.WaitingForPeer,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Rejected,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.99", 41_999), Endpoint("203.0.113.99", 51_999)));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Introduced,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.2.10", 42_000), Endpoint("198.51.100.40", 52_000)));
|
||||||
|
|
||||||
|
Assert.Equal(Endpoint("203.0.113.20", 51_000), Assert.Single(sink.Plans).HostPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConcurrentAttemptsForOneSessionNeverCrossWire()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials first = CreateAttempt(fixture, registration, "parallel-1");
|
||||||
|
AttemptCredentials second = CreateAttempt(fixture, registration, "parallel-2");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
_ = Process(processor, sink, first, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("10.0.0.10", 41_001), Endpoint("203.0.113.10", 51_001));
|
||||||
|
_ = Process(processor, sink, second, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("10.0.0.20", 41_002), Endpoint("203.0.113.20", 51_002));
|
||||||
|
_ = Process(processor, sink, second, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("10.0.0.21", 42_002), Endpoint("198.51.100.20", 52_002));
|
||||||
|
_ = Process(processor, sink, first, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("10.0.0.11", 42_001), Endpoint("198.51.100.10", 52_001));
|
||||||
|
|
||||||
|
Assert.Equal(2, sink.Plans.Count);
|
||||||
|
Assert.Contains(sink.Plans, plan =>
|
||||||
|
plan.HostPublic.Equals(Endpoint("203.0.113.10", 51_001))
|
||||||
|
&& plan.ClientPublic.Equals(Endpoint("198.51.100.10", 52_001)));
|
||||||
|
Assert.Contains(sink.Plans, plan =>
|
||||||
|
plan.HostPublic.Equals(Endpoint("203.0.113.20", 51_002))
|
||||||
|
&& plan.ClientPublic.Equals(Endpoint("198.51.100.20", 52_002)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentDuplicateCompletionEmitsExactlyOneIntroduction()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "completion-race");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
ConcurrentIntroductionSink sink = new();
|
||||||
|
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
|
||||||
|
using Barrier barrier = new(2);
|
||||||
|
|
||||||
|
Task<NatMediationResult>[] completions = Enumerable.Range(0, 2)
|
||||||
|
.Select(_ => Task.Run(() =>
|
||||||
|
{
|
||||||
|
barrier.SignalAndWait();
|
||||||
|
return Process(processor, sink, attempt, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000));
|
||||||
|
}))
|
||||||
|
.ToArray();
|
||||||
|
NatMediationResult[] results = await Task.WhenAll(completions);
|
||||||
|
|
||||||
|
Assert.Single(results, result => result == NatMediationResult.Introduced);
|
||||||
|
Assert.Single(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancellationCannotReportSuccessAfterIntroductionIsConsumed()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "cancel-race");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
using BlockingIntroductionSink sink = new();
|
||||||
|
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
|
||||||
|
Task<NatMediationResult> completion = Task.Run(() => Process(
|
||||||
|
processor,
|
||||||
|
sink,
|
||||||
|
attempt,
|
||||||
|
NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000),
|
||||||
|
Endpoint("198.51.100.40", 52_000)));
|
||||||
|
Assert.True(sink.WaitUntilEntered(TimeSpan.FromSeconds(2)));
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<bool> cancelled = fixture.Service.Cancel(
|
||||||
|
attempt.AttemptId,
|
||||||
|
attempt.ClientCapability);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousErrorCode.Conflict, cancelled.Error);
|
||||||
|
sink.Release();
|
||||||
|
Assert.Equal(NatMediationResult.Introduced, await completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateFloodAmortizesGlobalExpiryMaintenance()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials attempt = CreateAttempt(fixture, registration, "maintenance-budget");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
long before = fixture.Sessions.Store.MaintenanceSweepCount;
|
||||||
|
|
||||||
|
for (int index = 0; index < 256; index++)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.WaitingForPeer,
|
||||||
|
Process(processor, sink, attempt, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.InRange(fixture.Sessions.Store.MaintenanceSweepCount - before, 0, 1);
|
||||||
|
Assert.Empty(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingStaleCancelledAndMalformedRequestsNeverIntroduce()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials stale = CreateAttempt(fixture, registration, "stale");
|
||||||
|
AttemptCredentials cancelled = CreateAttempt(fixture, registration, "cancelled");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.WaitingForPeer,
|
||||||
|
Process(processor, sink, stale, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000)));
|
||||||
|
Assert.True(fixture.Service.Cancel(cancelled.AttemptId, cancelled.ClientCapability).Succeeded);
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
Process(processor, sink, cancelled, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("192.168.1.12", 42_001), Endpoint("198.51.100.41", 52_001)));
|
||||||
|
|
||||||
|
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(21));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
Process(processor, sink, stale, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
processor.ProcessRequest(
|
||||||
|
Endpoint("192.168.1.10", 41_000),
|
||||||
|
Endpoint("203.0.113.20", 51_000),
|
||||||
|
"malformed",
|
||||||
|
sink));
|
||||||
|
Assert.Empty(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddressFamiliesMustMatchAndOnlyGlobalIpv6SourcesAreAccepted()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
AttemptCredentials mismatch = CreateAttempt(fixture, registration, "family-mismatch");
|
||||||
|
AttemptCredentials ipv6 = CreateAttempt(fixture, registration, "ipv6");
|
||||||
|
NatMediationProcessor processor = CreateProcessor(fixture);
|
||||||
|
CaptureIntroductionSink sink = new();
|
||||||
|
|
||||||
|
byte[] shortFrozenIpv6 = RendezvousUdpCodec.Encode(new PresenceDatagram
|
||||||
|
{
|
||||||
|
MessageType = UdpPresenceMessageType.ClientPresence,
|
||||||
|
MediationHandle = ipv6.Handle,
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv6,
|
||||||
|
LocalAddress = "fd00::11",
|
||||||
|
LocalPort = 42_000,
|
||||||
|
Capability = ipv6.ClientCapability,
|
||||||
|
});
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
processor.ProcessDatagram(
|
||||||
|
shortFrozenIpv6,
|
||||||
|
Endpoint("2606:4700:4700::1001", 52_000),
|
||||||
|
sink));
|
||||||
|
|
||||||
|
_ = Process(processor, sink, mismatch, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Rejected,
|
||||||
|
Process(processor, sink, mismatch, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1111", 52_000)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
Process(processor, sink, ipv6, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("fd00::10", 41_000), Endpoint("2001:db8::10", 51_000)));
|
||||||
|
_ = Process(processor, sink, ipv6, NatPunchPeerRole.Host,
|
||||||
|
Endpoint("fd00::10", 41_000), Endpoint("2606:4700:4700::1000", 51_000));
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Introduced,
|
||||||
|
Process(processor, sink, ipv6, NatPunchPeerRole.Client,
|
||||||
|
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1001", 52_000)));
|
||||||
|
Assert.Single(sink.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NatMediationProcessor CreateProcessor(JoinAttemptFixture fixture) => new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
|
||||||
|
private static AttemptCredentials CreateAttempt(
|
||||||
|
JoinAttemptFixture fixture,
|
||||||
|
RegisterSessionResponse registration,
|
||||||
|
string idempotencyKey)
|
||||||
|
{
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, idempotencyKey);
|
||||||
|
HostJoinAttempt host = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
return new(
|
||||||
|
created.AttemptId,
|
||||||
|
created.MediationHandle,
|
||||||
|
host.HostPunchCapability,
|
||||||
|
created.ClientPunchCapability);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NatMediationResult Process(
|
||||||
|
NatMediationProcessor processor,
|
||||||
|
INatIntroductionSink sink,
|
||||||
|
AttemptCredentials attempt,
|
||||||
|
NatPunchPeerRole role,
|
||||||
|
IPEndPoint local,
|
||||||
|
IPEndPoint observed) => processor.ProcessRequest(
|
||||||
|
local,
|
||||||
|
observed,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
role,
|
||||||
|
attempt.Handle,
|
||||||
|
role == NatPunchPeerRole.Client
|
||||||
|
? attempt.ClientCapability
|
||||||
|
: attempt.HostCapability),
|
||||||
|
sink);
|
||||||
|
|
||||||
|
private static IPEndPoint Endpoint(string address, int port) =>
|
||||||
|
new(IPAddress.Parse(address), port);
|
||||||
|
|
||||||
|
private sealed record AttemptCredentials(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
MediationHandle Handle,
|
||||||
|
string HostCapability,
|
||||||
|
string ClientCapability);
|
||||||
|
|
||||||
|
private sealed class CaptureIntroductionSink : INatIntroductionSink
|
||||||
|
{
|
||||||
|
public List<NatIntroductionPlan> Plans { get; } = [];
|
||||||
|
|
||||||
|
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ConcurrentIntroductionSink : INatIntroductionSink
|
||||||
|
{
|
||||||
|
public ConcurrentBag<NatIntroductionPlan> Plans { get; } = [];
|
||||||
|
|
||||||
|
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingIntroductionSink : INatIntroductionSink, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ManualResetEventSlim _entered = new();
|
||||||
|
private readonly ManualResetEventSlim _release = new();
|
||||||
|
|
||||||
|
public void Introduce(NatIntroductionPlan plan)
|
||||||
|
{
|
||||||
|
_entered.Set();
|
||||||
|
_release.Wait(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool WaitUntilEntered(TimeSpan timeout) => _entered.Wait(timeout);
|
||||||
|
public void Release() => _release.Set();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_entered.Dispose();
|
||||||
|
_release.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
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.JoinAttempts;
|
||||||
using FinalFactory.Rendezvous.Tests.State;
|
using LiteNetLib;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -13,55 +12,24 @@ namespace FinalFactory.Rendezvous.Tests.Server;
|
|||||||
public sealed class UdpMediatorServiceTests
|
public sealed class UdpMediatorServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuthenticatedHostDatagramGatesVisibilityUsingObservedGameplaySocket()
|
public async Task ServiceBindsAnEphemeralLiteNetLibPortAndStopsCleanly()
|
||||||
{
|
|
||||||
using SessionLeaseFixture fixture = new();
|
|
||||||
RegisterSessionResponse registration = fixture.Register();
|
|
||||||
using UdpMediatorService service = new(
|
|
||||||
Options.Create(new UdpMediatorOptions { ListenAddress = "127.0.0.1", Port = 0 }),
|
|
||||||
NullLogger<UdpMediatorService>.Instance,
|
|
||||||
fixture.Store,
|
|
||||||
fixture.Capabilities);
|
|
||||||
PresenceDatagram presence = new()
|
|
||||||
{
|
|
||||||
MessageType = UdpPresenceMessageType.HostPresence,
|
|
||||||
MediationHandle = registration.HostPresenceHandle,
|
|
||||||
AddressFamily = AddressFamilyKind.Ipv4,
|
|
||||||
LocalAddress = "192.168.1.50",
|
|
||||||
LocalPort = 40_000,
|
|
||||||
Capability = registration.HostPresenceCapability,
|
|
||||||
};
|
|
||||||
IPEndPoint observedGameplaySocket = new(IPAddress.Parse("203.0.113.77"), 51_234);
|
|
||||||
presence.Capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
||||||
Assert.Equal(
|
|
||||||
UdpPresenceProcessingResult.HostPresenceRejected,
|
|
||||||
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
|
||||||
Assert.Empty(fixture.Browse());
|
|
||||||
|
|
||||||
presence.Capability = registration.HostPresenceCapability;
|
|
||||||
Assert.Equal(
|
|
||||||
UdpPresenceProcessingResult.HostPresenceAccepted,
|
|
||||||
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
|
||||||
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
|
|
||||||
{
|
{
|
||||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
UdpMediatorOptions options = new()
|
using JoinAttemptFixture fixture = new();
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
{
|
{
|
||||||
ListenAddress = IPAddress.Loopback.ToString(),
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
Port = 0,
|
Port = 0,
|
||||||
};
|
MaxDatagramsPerPoll = 8,
|
||||||
ManualRendezvousClock clock = new();
|
PollIntervalMilliseconds = 1,
|
||||||
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock);
|
}),
|
||||||
using EphemeralCapabilityIssuer capabilities = new();
|
|
||||||
using UdpMediatorService service = new(
|
|
||||||
Options.Create(options),
|
|
||||||
NullLogger<UdpMediatorService>.Instance,
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
store,
|
processor);
|
||||||
capabilities);
|
|
||||||
|
|
||||||
await service.StartAsync(timeout.Token);
|
await service.StartAsync(timeout.Token);
|
||||||
|
|
||||||
@@ -69,9 +37,300 @@ public sealed class UdpMediatorServiceTests
|
|||||||
Assert.NotNull(boundEndpoint);
|
Assert.NotNull(boundEndpoint);
|
||||||
Assert.Equal(IPAddress.Loopback, boundEndpoint.Address);
|
Assert.Equal(IPAddress.Loopback, boundEndpoint.Address);
|
||||||
Assert.InRange(boundEndpoint.Port, 1, 65_535);
|
Assert.InRange(boundEndpoint.Port, 1, 65_535);
|
||||||
|
Assert.Null(service.LocalIpv6Endpoint);
|
||||||
|
|
||||||
await service.StopAsync(timeout.Token);
|
await service.StopAsync(timeout.Token);
|
||||||
|
|
||||||
Assert.Null(service.LocalEndpoint);
|
Assert.Null(service.LocalEndpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OptionalIpv6BindingNeverWidensTheRequiredIpv4Binding()
|
||||||
|
{
|
||||||
|
if (!Socket.OSSupportsIPv6)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Ipv6ListenAddress = IPAddress.IPv6Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
|
||||||
|
Assert.Equal(IPAddress.Loopback, service.LocalEndpoint!.Address);
|
||||||
|
Assert.Equal(IPAddress.IPv6Loopback, service.LocalIpv6Endpoint!.Address);
|
||||||
|
Assert.Equal(service.LocalEndpoint.Port, service.LocalIpv6Endpoint.Port);
|
||||||
|
IPAddress? otherIpv4 = Dns.GetHostAddresses(Dns.GetHostName())
|
||||||
|
.FirstOrDefault(address =>
|
||||||
|
address.AddressFamily == AddressFamily.InterNetwork
|
||||||
|
&& !IPAddress.IsLoopback(address));
|
||||||
|
if (otherIpv4 is not null)
|
||||||
|
{
|
||||||
|
using UdpClient scopeProbe = new(new IPEndPoint(otherIpv4, service.LocalEndpoint.Port));
|
||||||
|
Assert.Equal(otherIpv4, ((IPEndPoint)scopeProbe.Client.LocalEndPoint!).Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.StopAsync(timeout.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NativeLiteNetLibRequestsIntroduceTheAuthorizedPair()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "native-litenet");
|
||||||
|
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
MaxDatagramsPerPoll = 8,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
|
||||||
|
EventBasedNetListener hostListener = new();
|
||||||
|
EventBasedNetListener clientListener = new();
|
||||||
|
NetManager host = new(hostListener) { NatPunchEnabled = true };
|
||||||
|
NetManager client = new(clientListener) { NatPunchEnabled = true };
|
||||||
|
EventBasedNatPunchListener hostPunch = new();
|
||||||
|
EventBasedNatPunchListener clientPunch = new();
|
||||||
|
List<string> hostTickets = [];
|
||||||
|
List<string> clientTickets = [];
|
||||||
|
hostPunch.NatIntroductionSuccess += (_, _, ticket) => hostTickets.Add(ticket);
|
||||||
|
clientPunch.NatIntroductionSuccess += (_, _, ticket) => clientTickets.Add(ticket);
|
||||||
|
host.NatPunchModule.Init(hostPunch);
|
||||||
|
client.NatPunchModule.Init(clientPunch);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(host.Start(0));
|
||||||
|
Assert.True(client.Start(0));
|
||||||
|
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||||
|
host.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
created.MediationHandle,
|
||||||
|
hostAttempt.HostPunchCapability));
|
||||||
|
client.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Client,
|
||||||
|
created.MediationHandle,
|
||||||
|
created.ClientPunchCapability));
|
||||||
|
|
||||||
|
while ((hostTickets.Count == 0 || clientTickets.Count == 0)
|
||||||
|
&& !timeout.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
host.PollEvents();
|
||||||
|
host.NatPunchModule.PollEvents();
|
||||||
|
client.PollEvents();
|
||||||
|
client.NatPunchModule.PollEvents();
|
||||||
|
await Task.Delay(5, timeout.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal));
|
||||||
|
string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal));
|
||||||
|
Assert.Equal(hostTicket, clientTicket);
|
||||||
|
Assert.Equal(43, hostTicket.Length);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
host.Stop();
|
||||||
|
client.Stop();
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrozenV1EnvelopeIsConsumedOnTheLiteNetSocketWithinAmplificationBudget()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "v1-envelope");
|
||||||
|
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
MaxDatagramsPerPoll = 8,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
using UdpClient host = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
using UdpClient client = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||||
|
byte[] hostDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
|
||||||
|
{
|
||||||
|
MessageType = UdpPresenceMessageType.HostPresence,
|
||||||
|
MediationHandle = created.MediationHandle,
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
LocalAddress = "192.168.1.10",
|
||||||
|
LocalPort = 41_000,
|
||||||
|
Capability = hostAttempt.HostPunchCapability,
|
||||||
|
});
|
||||||
|
byte[] clientDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
|
||||||
|
{
|
||||||
|
MessageType = UdpPresenceMessageType.ClientPresence,
|
||||||
|
MediationHandle = created.MediationHandle,
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
LocalAddress = "192.168.1.11",
|
||||||
|
LocalPort = 42_000,
|
||||||
|
Capability = created.ClientPunchCapability,
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await host.SendAsync(hostDatagram, mediator, timeout.Token);
|
||||||
|
await client.SendAsync(clientDatagram, mediator, timeout.Token);
|
||||||
|
UdpReceiveResult hostIntroduction = await host.ReceiveAsync(timeout.Token);
|
||||||
|
UdpReceiveResult clientIntroduction = await client.ReceiveAsync(timeout.Token);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
|
||||||
|
<= clientDatagram.Length * 2,
|
||||||
|
"The completing authenticated contribution exceeded the 2.0 response-byte budget.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OversizedMalformedAndGameplayDatagramsReceiveNoResponse()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
MaxDatagramsPerPoll = 8,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
using UdpClient sender = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||||
|
byte[] oversized = new byte[ContractLimits.UdpDatagramMaxBytes + 1];
|
||||||
|
oversized[0] = RendezvousUdpCodec.MagicFirst;
|
||||||
|
oversized[1] = RendezvousUdpCodec.MagicSecond;
|
||||||
|
byte[] gameplayPayload = [0x01, 0x02, 0x03, 0x04];
|
||||||
|
byte[] malformedNative = [17, 0];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await sender.SendAsync(oversized, mediator, timeout.Token);
|
||||||
|
await sender.SendAsync(gameplayPayload, mediator, timeout.Token);
|
||||||
|
await sender.SendAsync(malformedNative, mediator, timeout.Token);
|
||||||
|
using CancellationTokenSource noResponse = new(TimeSpan.FromMilliseconds(150));
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||||
|
await sender.ReceiveAsync(noResponse.Token));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForgedNativeIntroductionResponseCannotReflectToPayloadEndpoint()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService service = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
MaxDatagramsPerPoll = 8,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await service.StartAsync(timeout.Token);
|
||||||
|
using UdpClient reflectedTarget = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
using UdpClient responseCapture = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
using UdpClient attacker = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
LiteNetManager generator = new(new EventBasedLiteNetListener()) { NatPunchEnabled = true };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(generator.Start(0));
|
||||||
|
IPEndPoint target = (IPEndPoint)reflectedTarget.Client.LocalEndPoint!;
|
||||||
|
IPEndPoint capture = (IPEndPoint)responseCapture.Client.LocalEndPoint!;
|
||||||
|
generator.NatPunchModule.NatIntroduce(
|
||||||
|
target,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 9),
|
||||||
|
capture,
|
||||||
|
capture,
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||||
|
byte[] forgedResponse = (await responseCapture.ReceiveAsync(timeout.Token)).Buffer;
|
||||||
|
|
||||||
|
await attacker.SendAsync(
|
||||||
|
forgedResponse,
|
||||||
|
Assert.IsType<IPEndPoint>(service.LocalEndpoint),
|
||||||
|
timeout.Token);
|
||||||
|
|
||||||
|
using CancellationTokenSource noReflection = new(TimeSpan.FromMilliseconds(150));
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||||
|
await reflectedTarget.ReceiveAsync(noReflection.Token));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
generator.Stop();
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ public sealed class SessionLeaseServiceTests
|
|||||||
{
|
{
|
||||||
LeaseLifetime = TimeSpan.FromSeconds(5),
|
LeaseLifetime = TimeSpan.FromSeconds(5),
|
||||||
JoinAttemptLifetime = TimeSpan.FromSeconds(5),
|
JoinAttemptLifetime = TimeSpan.FromSeconds(5),
|
||||||
|
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
|
||||||
IdempotencyLifetime = TimeSpan.FromSeconds(6),
|
IdempotencyLifetime = TimeSpan.FromSeconds(6),
|
||||||
};
|
};
|
||||||
using SessionLeaseFixture fixture = new(options);
|
using SessionLeaseFixture fixture = new(options);
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ internal sealed class EphemeralStateFixture
|
|||||||
ProtocolVersion = listing.Definition.ProtocolVersion,
|
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||||
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
|
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
|
||||||
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
|
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
|
||||||
|
ConnectionTicketFingerprint = Fingerprint($"ticket-{sequence}"),
|
||||||
|
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.State;
|
||||||
|
|
||||||
|
public sealed class StoreResultMappingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EveryStoreResultHasOneSharedContractErrorMapping()
|
||||||
|
{
|
||||||
|
Dictionary<StoreResultCode, RendezvousErrorCode> expected = new()
|
||||||
|
{
|
||||||
|
[StoreResultCode.Success] = RendezvousErrorCode.None,
|
||||||
|
[StoreResultCode.NotFound] = RendezvousErrorCode.NotFound,
|
||||||
|
[StoreResultCode.Expired] = RendezvousErrorCode.Expired,
|
||||||
|
[StoreResultCode.Revoked] = RendezvousErrorCode.Forbidden,
|
||||||
|
[StoreResultCode.Conflict] = RendezvousErrorCode.Conflict,
|
||||||
|
[StoreResultCode.CapacityExceeded] = RendezvousErrorCode.CapacityExceeded,
|
||||||
|
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
|
||||||
|
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count);
|
||||||
|
foreach (KeyValuePair<StoreResultCode, RendezvousErrorCode> item in expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(item.Value, item.Key.ToContractError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
|
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult
|
||||||
|
ENUM Accepted=1
|
||||||
|
ENUM NotFound=2
|
||||||
|
ENUM Expired=3
|
||||||
|
ENUM Rejected=4
|
||||||
|
ENUM AlreadyConsumed=5
|
||||||
|
ENUM Revoked=6
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
|
||||||
|
CTOR (System.Int32 maximumAuthorizedTickets)
|
||||||
|
METHOD FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult Consume(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
|
||||||
|
METHOD System.Void Dispose()
|
||||||
|
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
|
||||||
|
METHOD System.String ToString()
|
||||||
|
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
|
||||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
|
||||||
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
|
||||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
|
|||||||
FIELD System.Int32 ConnectionTicketMaxCharacters=192
|
FIELD System.Int32 ConnectionTicketMaxCharacters=192
|
||||||
FIELD System.Int32 ContractVersion=1
|
FIELD System.Int32 ContractVersion=1
|
||||||
FIELD System.Int32 CursorMaxCharacters=512
|
FIELD System.Int32 CursorMaxCharacters=512
|
||||||
|
FIELD System.Int32 DerivedCredentialCharacters=43
|
||||||
FIELD System.Int32 DiagnosticCodeMaxCharacters=64
|
FIELD System.Int32 DiagnosticCodeMaxCharacters=64
|
||||||
FIELD System.Int32 DisplayNameMaxBytes=128
|
FIELD System.Int32 DisplayNameMaxBytes=128
|
||||||
FIELD System.Int32 EnvironmentIdMaxCharacters=32
|
FIELD System.Int32 EnvironmentIdMaxCharacters=32
|
||||||
@@ -61,6 +62,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
|
|||||||
FIELD System.Int32 MetadataMaxBytes=4096
|
FIELD System.Int32 MetadataMaxBytes=4096
|
||||||
FIELD System.Int32 MetadataMaxKeys=32
|
FIELD System.Int32 MetadataMaxKeys=32
|
||||||
FIELD System.Int32 MetadataValueMaxBytes=256
|
FIELD System.Int32 MetadataValueMaxBytes=256
|
||||||
|
FIELD System.Int32 NatPunchRequestTokenCharacters=192
|
||||||
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
|
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
|
||||||
FIELD System.Int32 RegionIdMaxCharacters=32
|
FIELD System.Int32 RegionIdMaxCharacters=32
|
||||||
FIELD System.Int32 SessionCapacityMaxPlayers=10000
|
FIELD System.Int32 SessionCapacityMaxPlayers=10000
|
||||||
@@ -171,6 +173,20 @@ TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
|
|||||||
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
|
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
|
||||||
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
||||||
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
||||||
|
TYPE FinalFactory.Rendezvous.Contracts.NatPunchPeerRole
|
||||||
|
ENUM HostPresence=1
|
||||||
|
ENUM Host=2
|
||||||
|
ENUM Client=3
|
||||||
|
TYPE FinalFactory.Rendezvous.Contracts.NatPunchRequestToken
|
||||||
|
CTOR ()
|
||||||
|
PROP System.String Capability {get;set;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.NatPunchPeerRole Role {get;set;}
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Contracts.NatPunchRequestTokenCodec
|
||||||
|
FIELD System.Int32 EncodedLength=192
|
||||||
|
METHOD System.String Encode(FinalFactory.Rendezvous.Contracts.NatPunchPeerRole role, FinalFactory.Rendezvous.Contracts.MediationHandle mediationHandle, System.String capability)
|
||||||
|
METHOD System.Boolean TryDecode(System.String encoded, FinalFactory.Rendezvous.Contracts.NatPunchRequestToken& token)
|
||||||
TYPE FinalFactory.Rendezvous.Contracts.NetworkEndpoint
|
TYPE FinalFactory.Rendezvous.Contracts.NetworkEndpoint
|
||||||
CTOR ()
|
CTOR ()
|
||||||
PROP System.String Address {get;set;}
|
PROP System.String Address {get;set;}
|
||||||
|
|||||||
Reference in New Issue
Block a user