Compare commits

..

7 Commits

Author SHA1 Message Date
KyuubiYoru be732de7c9 feat(server): add observability and operator controls (#16)
quality-gate / quality (push) Failing after 1m1s
2026-07-16 13:22:16 +02:00
KyuubiYoru 88ef946af5 feat(server): harden hostile input and overload behavior (#15)
quality-gate / quality (push) Failing after 1m5s
2026-07-16 12:37:38 +02:00
KyuubiYoru 2ff7cd6d9d test(integration): add deterministic NAT topology harness (#14)
quality-gate / quality (push) Failing after 1m3s
2026-07-16 11:50:53 +02:00
KyuubiYoru 7e3be2cad1 feat(tooling): add standalone rendezvous test client (#25)
quality-gate / quality (push) Failing after 1m3s
2026-07-16 11:05:56 +02:00
KyuubiYoru 94aba8a3bb feat(client): standardize connection outcomes (#13)
quality-gate / quality (push) Successful in 59s
2026-07-16 10:18:41 +02:00
KyuubiYoru b4b6072fe1 feat(client): add rendezvous traversal coordinators (#12)
quality-gate / quality (push) Successful in 56s
2026-07-16 08:39:05 +02:00
KyuubiYoru 6d076c281a feat: implement authenticated NAT mediator (#11)
quality-gate / quality (push) Successful in 59s
Closes #11
2026-07-16 07:37:02 +02:00
105 changed files with 17355 additions and 431 deletions
+53
View File
@@ -35,3 +35,56 @@ jobs:
- name: Test - name: Test
run: dotnet test Rendezvous.slnx --configuration Release --no-build run: dotnet test Rendezvous.slnx --configuration Release --no-build
- name: Test privileged Linux namespace topology when available
shell: bash
run: |
set -euo pipefail
probe="rendezvous-probe-$$"
suffix="$(( $$ % 100000 ))"
bridge="rvb${suffix}"
veth_root="rvr${suffix}"
veth_peer="rvp${suffix}"
cleanup_probe() {
if [[ -n "$veth_root" ]]; then
ip link delete "$veth_root" >/dev/null 2>&1 || true
fi
if [[ -n "$bridge" ]]; then
ip link delete "$bridge" >/dev/null 2>&1 || true
fi
if [[ -n "$probe" ]]; then
ip netns delete "$probe" >/dev/null 2>&1 || true
fi
}
trap cleanup_probe EXIT
if command -v ip >/dev/null 2>&1 \
&& command -v iptables >/dev/null 2>&1 \
&& command -v sysctl >/dev/null 2>&1 \
&& ip netns add "$probe" 2>/dev/null \
&& ip link add "$bridge" type bridge \
&& ip link add "$veth_root" type veth peer name "$veth_peer" \
&& ip link set "$veth_root" master "$bridge" \
&& ip link set "$veth_peer" netns "$probe" \
&& ip netns exec "$probe" sysctl -q -w net.ipv4.ip_forward=1 \
&& ip netns exec "$probe" iptables -t nat -A POSTROUTING -o "$veth_peer" -j MASQUERADE \
&& ip netns exec "$probe" iptables -A FORWARD -i "$veth_peer" -o lo \
-m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; then
ip link delete "$veth_root"
veth_root=""
ip link delete "$bridge"
bridge=""
ip netns delete "$probe"
probe=""
results="${RUNNER_TEMP:-/tmp}/rendezvous-netns-results"
mkdir -p "$results"
RENDEZVOUS_RUN_NETNS_TESTS=1 dotnet test Rendezvous.slnx \
--configuration Release \
--no-build \
--filter FullyQualifiedName~PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints \
--logger "trx;LogFileName=netns.trx" \
--results-directory "$results"
grep -q 'testName="[^"]*\.PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints"' \
"$results/netns.trx"
else
echo "Network namespaces/NAT tooling unavailable; deterministic loopback topology remains the required gate."
fi
+20 -3
View File
@@ -17,7 +17,7 @@ Rendezvous is intended to provide:
- Isolation by game, environment, protocol version, and region. - Isolation by game, environment, protocol version, and region.
- Operational health, metrics, logging, administration, and rate limiting. - Operational health, metrics, logging, administration, and rate limiting.
UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server or a future relay service. UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server. The v1 SDK returns an optional game-configured endpoint for an explicit caller decision; it never routes automatically, and v1 does not provide a relay.
## Connection flow ## Connection flow
@@ -75,7 +75,13 @@ The initial service does not provide:
## Project status ## Project status
Rendezvous is currently in its initial design and bootstrap stage. The first implementation should establish the contracts, directory leases, LiteNetLib mediator, client SDK, thin test client, and a three-party integration test before either game depends on it for production connectivity. Rendezvous is under active roadmap development. The versioned contracts,
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
SDK coordination, typed connection outcomes, and thin public-SDK diagnostic client
are implemented. Deployment hardening, the broader NAT-topology harness, and
the production-readiness roadmap remain in progress;
participating games must not treat the current repository as a finished production
service until those gates land.
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/README.md). threat model are indexed in [the architecture documentation](docs/architecture/README.md).
@@ -83,6 +89,16 @@ The frozen v1 wire surface is documented in the
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md). [HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
Tenant policy, publisher/operator principals, and production key custody are Tenant policy, publisher/operator principals, and production key custody are
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md). defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
Layered HTTP/UDP budgets, overload behavior, and safe operational tuning are
defined in [hostile-input and overload protection](docs/security/abuse-protection.md).
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
operator controls are defined in the
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
The scriptable host/browser/join diagnostic and its stable automation contract are
documented in the [TestClient integration guide](docs/integration/test-client.md).
The always-on three-party scenarios, optional Linux namespace topology, and
simulation limits are documented in the
[deterministic topology harness](docs/integration/topology-harness.md).
## Development ## Development
@@ -99,7 +115,8 @@ dotnet test Rendezvous.slnx --configuration Release --no-build
Run the bootstrap server with Run the bootstrap server with
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds `dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
the configured UDP mediator port; both stop through normal host cancellation. the configured UDP mediator port; both stop through normal host cancellation.
The launch profile uses an ephemeral development-only signing key. Production The launch profile uses separate ephemeral development-only publisher and operator
signing keys. Production
startup fails closed until externally supplied game policies and `env:` signing startup fails closed until externally supplied game policies and `env:` signing
key references resolve to valid key material; no reusable game secret is stored key references resolve to valid key material; no reusable game secret is stored
in this repository or the public Client package. in this repository or the public Client package.
File diff suppressed because it is too large Load Diff
@@ -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
@@ -24,18 +24,24 @@ credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC
key, the client subject, the complete canonical request fingerprint, a fresh salt, 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), and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
below both the 192-character Rendezvous capability ceiling and LiteNetLib's below both the 192-character Rendezvous capability ceiling and LiteNetLib's
256-character NAT token ceiling. State retains keyed credential fingerprints, 256-character NAT token ceiling. The connection ticket uses half of that payload
derivation inputs, and salt—not issued plaintext. All diagnostic string for its attempt ID and half for an independently derived 128-bit authenticator, so
representations redact credentials and derivation material. the SDK can correlate concurrent introductions without increasing UDP response
size. State retains keyed credential fingerprints, derivation inputs, and salt—not
issued plaintext. All diagnostic string representations redact credentials and
derivation material.
The client receives only its punch capability. A host polls its own listing with The 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 the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
capabilities through a signed, listing-bound, five-minute cursor. Replaying an capabilities through a signed, listing-bound, five-minute cursor. Replaying an
identical join request returns the same live attempt; changing the request under 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 the same owner/key conflicts. A client may cancel with its punch capability in
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the `X-Rendezvous-Client-Punch-Capability`; cancellation atomically marks the attempt
attempt. Listing deletion, expiry, revocation, or process restart removes every and retains a bounded tombstone until its original expiry. Host polling returns
associated attempt and credential fingerprint. that tombstone so a coordinator can revoke any local ticket authorization, while
endpoint binding, introduction, ticket issuance, and ticket consumption all
reject the cancelled attempt. Listing deletion, expiry, revocation, or process
restart removes every associated attempt and credential fingerprint.
Endpoint binding remains role- and capability-specific. The first endpoint Endpoint binding remains role- and capability-specific. The first endpoint
observed for a role wins atomically; an exact UDP duplicate is idempotent, while observed for a role wins atomically; an exact UDP duplicate is idempotent, while
@@ -50,8 +56,14 @@ fingerprint-consumption seam for mediator tests and revocation. On the game host
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest, the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/ 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. revoked/replayed tickets, and zeroes retained digests and key material on disposal.
Issue #11 carries the ticket in the authenticated introduction; issue #12 wires Issue #11 carries the fixed-size ticket in the authenticated introduction. Issue
authorization and consumption into the caller-owned LiteNetLib coordinator. #12 extracts its embedded attempt ID, bounds the host's local authorization window
by both the host-polled attempt expiry and the configured ticket lifetime, then
wires one-time consumption into the caller-owned coordinator. Both peers receive
a digest of the exact expected ticket over HTTP and reject any syntactically valid
but unauthenticated introduction token. Embedding the ID prevents concurrent or
late introductions from cross-binding a valid ticket while preserving the
mediator's 2.0 response-byte amplification ceiling.
## Consequences ## Consequences
@@ -0,0 +1,68 @@
# ADR 0009: authenticated bounded LiteNetLib NAT mediator
- Status: Accepted
- Date: 2026-07-16
- Tracking: #11
## Decision
The server owns one LiteNetLib `NetManager` and its `NatPunchModule` on the
configured UDP endpoint. It runs in manual mode with a configured maximum number
of datagrams per poll and a short caller-owned poll interval. LiteNetLib events
are unsynchronized so authenticated requests are processed immediately on that
single polling path rather than accumulated in an unbounded event queue. The
mediator never accepts a LiteNetLib gameplay connection or handles application
payloads.
The packet layer also consumes the frozen v1 presence envelope on the same
socket. Native NAT requests use a canonical fixed-size 192-character token that
binds a role (`HostPresence`, attempt `Host`, or attempt `Client`), mediation
handle, and the already-issued capability. Both transports enter one processor
and the same atomic store operations. No transport-supplied public address is
trusted; the socket source is authoritative.
LiteNetLib's native NAT packet family also contains introduction-response and
punch frames that are appropriate for peers but unsafe on a public mediator: a
forged response can name arbitrary destinations. The packet layer therefore
decodes only the pinned `NatIntroduceRequest` wire shape and consumes every
inbound packet before `NatPunchModule` sees it. The module is outbound-only and
may send introductions solely from a completed authorized plan.
Listing presence refreshes authorize no response. Attempt contributions bind the
first observed endpoint for exactly one capability role. Exact duplicates are
idempotent; a different endpoint, the opposite role, an expired/cancelled
attempt, or a stale listing presence cannot replace it. The introduction is
consumed atomically only after both roles bind and their observed address
families match, preventing concurrent attempts for one listing from cross-wiring.
A reported local candidate is eligible only when it is RFC 1918 IPv4 or IPv6
unique-local unicast, matches the observed family, and both peers have the same
observed public address. Otherwise `NatIntroduce` receives the observed public
endpoint in the local slot. Loopback, link-local, multicast, unspecified,
documentation IPv6, global-address claims, and cross-family claims are never
disclosed as local targets. IPv4 is required; observed global IPv6 can be used
when both peers contribute IPv6, without claiming guaranteed IPv6 NAT traversal.
The introduction carries only the distinct connection ticket and is emitted at
most once to each verified observed endpoint. The fixed authenticated native
request and bounded frozen envelope keep the combined response bytes within the
2.0 verified amplification budget; unauthenticated inputs receive zero bytes.
Malformed, truncated, oversized, spoofed, or unrelated LiteNetLib packets do not
grow Rendezvous state. Raw endpoints and credentials are never logged or exposed
through diagnostic string representations.
Frozen IPv6 listing-presence refresh remains valid because it emits no response.
IPv6 attempt roles require the fixed-size native LiteNetLib request; accepting the
short frozen envelope would exceed the 2.0 byte budget for two IPv6 introduction
frames. The required IPv4 listen address and optional IPv6 listen address are
configured separately so enabling one family never widens the other family to a
wildcard bind.
## Consequences
- Hosts refresh listing presence and answer invitations from their actual
gameplay socket; a separate mediator socket would observe the wrong mapping.
- Caller-owned SDK coordination in #12 must poll the host invitation endpoint,
send the corresponding native role token, and consume the returned ticket.
- UDP loss can prevent traversal, but it cannot cause an arbitrary destination,
replay, role substitution, or cross-attempt introduction.
@@ -0,0 +1,117 @@
# ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback
- Status: Accepted
- Date: 2026-07-16
- Tracking: #13
## Context
A connection can stop in the directory, authorization, mediation, NAT traversal,
or direct-connection phase. Those failures have different authorities: an HTTP
response can authoritatively reject a join, the SDK can observe a local timeout,
and only the remote host can reject a direct connection. Treating all of them as
one message or generic timeout would make player guidance, retry policy, tests,
and operational measurements unreliable.
UDP loss, service silence, cancellation, and late LiteNetLib callbacks also make
completion races unavoidable. Games need one terminal result and bounded work,
not a sequence of contradictory callbacks. Direct traversal cannot be guaranteed,
but v1 has no gameplay relay and must not imply otherwise.
## Decision
### Closed typed outcome model
`ConnectionOutcomeKind` is the stable wire-level terminal set: connected,
cancelled, directory not found, attempt expired, incompatible protocol,
unauthorized, rate limited, no host presence, service unavailable or rejected,
mediator unavailable, punch timeout, direct-connect timeout, host rejection,
transport error, manager stopped, and disposed.
The already-frozen v1 members `TimedOut`, `StaleHost`, `TransportFailed`, and
`FallbackOffered` retain their original numeric values for source and wire
compatibility. New SDK code never emits them. The report service accepts them,
normalizes the first three to their precise modern equivalents, and does not let
legacy compatibility weaken the typed coordinator result.
The client adds `RendezvousConnectionOutcomeSource`, failure category, and phase.
These fields preserve authority instead of guessing from text:
- `RendezvousService` is used only for an HTTP decision or bounded service
silence. Its optional `ServiceError` retains the stable service error code.
- `LocalTraversal` reports local punch, direct-connect, and transport
observations.
- `RemoteHost` reports an explicit direct-connection rejection.
- `Caller` and `Lifecycle` distinguish cancellation from manager shutdown or
disposal.
Messages remain diagnostic and are never parsed into outcomes. A successful NAT
introduction is only a transition to direct connection; `Connected` is emitted
only after LiteNetLib reports the authenticated peer connected.
Join issuance is exposed as `RendezvousConnectionStartResult`, containing exactly
one issued attempt or one terminal service outcome. Once an attempt is issued,
the coordinator owns its local terminal outcome. Completion is exactly once;
terminal paths release SDK subscriptions so late introductions, peer callbacks,
network errors, cancellation, and polling are inert.
### Bounded phases and retries
Each HTTP try has a five-second default silence budget, configurable from above
zero through thirty seconds. Only safe operations use the existing bounded retry
policy, honoring caller cancellation and server retry guidance. Exhausting that
budget returns `ServiceUnavailable`; it never waits indefinitely.
Traversal has independent defaults: ten seconds for punch/mediation and five
seconds for the direct connection. Both are configurable up to thirty seconds.
Local budgets, retry schedules, and elapsed duration use monotonic time, so a
wall-clock correction cannot extend them or produce a negative duration. The
signed attempt expiry is converted to an additional monotonic upper bound when
the attempt is received. Punch retries retain
their bounded request count and exponential backoff; crossing a phase deadline
completes exactly once even if a delayed packet later arrives. Tests use an
injected clock and do not depend on wall-clock sleeps.
### Explicit dedicated fallback handoff
A publisher may attach one validated dedicated endpoint to registration or
update only when the tenant's provisioned fallback policy allows it. The server
copies that endpoint into browser and issued-attempt contracts.
The client coordinator defensively copies it into every terminal outcome; a game
may override it locally through `DedicatedFallbackOverride`.
The SDK never opens, dials, reserves, probes, or authenticates the fallback. The
game decides whether the outcome permits fallback, presents any player choice,
and connects through its own gameplay transport and admission rules. Absence of
an endpoint is an honest no-fallback result. Gameplay relay is absent from v1.
### Privacy-safe optional reporting
After an issued attempt completes, the game may explicitly report its outcome
with the short-lived client punch capability. Reporting is authenticated and
idempotent: an exact repeat succeeds as a duplicate, while a conflicting repeat
is rejected. Reports contain only an allowlisted outcome enum and one coarse
elapsed bucket (`<1s`, `15s`, `515s`, `1530s`, or `30s+`). They contain no
diagnostic message, exact duration, endpoint, metadata, player identifier, or
credential.
Frozen v1 DTOs still expose `elapsedMilliseconds` and `diagnosticCode`. They are
deprecated compatibility inputs: the current SDK omits them, the service
immediately buckets legacy elapsed time, and neither exact timing nor diagnostic
text is retained, logged, or used as a metric dimension.
The store retains a bounded capability-fingerprint tombstone long enough to
accept a report after the live attempt expires. Metrics count the first accepted
outcome only and use only outcome plus elapsed bucket as dimensions. Service
issuance failures cannot be reported because no attempt capability was issued.
## Consequences
- Player-facing UI can map stable outcome/category pairs to localized guidance
without exposing diagnostic strings.
- Service rejection, remote-host rejection, and local observation remain
distinguishable for retry and support decisions.
- Games own fallback policy and gameplay admission; Rendezvous does not claim a
guaranteed connection path.
- Outcome additions are contract changes and require OpenAPI, serialization,
public API, fake-clock, late-event, and idempotency coverage.
+2
View File
@@ -11,6 +11,8 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [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 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
- [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md)
- [ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback](0010-typed-connection-outcomes-and-fallback.md)
- [Threat model](../security/threat-model.md) - [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md) - [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md) - [Versioned HTTP and UDP contracts](../contracts/README.md)
+21 -5
View File
@@ -40,9 +40,7 @@ the same value as a required query parameter.
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. | | `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
shape reference for parameters, bodies, and responses. Contract-only endpoints shape reference for parameters, bodies, and responses.
return `501` until their behavior is implemented by the subsequent directory,
lease, and join-orchestration issues.
Host polling sends its reusable lease credential in Host polling sends its reusable lease credential in
`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials `X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
@@ -55,6 +53,24 @@ Attempt cancellation sends the short-lived client punch capability in
source only for a process-keyed, short-lived idempotency/abuse scope; this is not source only for a process-keyed, short-lived idempotency/abuse scope; this is not
player authentication and is never returned to callers. player authentication and is never returned to callers.
Outcome reporting uses that same short-lived capability. It accepts only outcomes
for an issued attempt and carries one stable outcome enum plus one coarse elapsed
bucket. Exact duplicate reports are idempotent; conflicting repeats fail. Reports
never carry exact timing, diagnostics, endpoints, metadata, player identifiers,
or credentials.
The frozen v1 .NET request also retains deprecated `elapsedMilliseconds` and
`diagnosticCode` properties for source/wire compatibility. Current clients omit
them. If a legacy client supplies them, the server immediately converts elapsed
milliseconds to the coarse bucket and discards diagnostic text; neither value is
retained or used as a metric dimension.
Registration and update may include one validated `dedicatedFallback`. The
endpoint must be enabled by the tenant's provisioned fallback policy, is visible
browser data, and is copied into subsequently issued attempts.
It is a handoff for caller-owned policy: neither the HTTP service nor the SDK
automatically connects to it. V1 provides no gameplay relay.
## Idempotency, cursors, and retries ## Idempotency, cursors, and retries
Registration and join creation require a caller-generated visible-ASCII Registration and join creation require a caller-generated visible-ASCII
@@ -101,9 +117,9 @@ must not be parsed. Secrets and raw credentials are never echoed.
| 401 | `authenticationRequired` | | 401 | `authenticationRequired` |
| 403 | `forbidden` | | 403 | `forbidden` |
| 404 | `notFound` | | 404 | `notFound` |
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` | | 409 | `conflict`, `incompatibleProtocol`, `replayRejected` |
| 410 | `expired`, `staleHost` | | 410 | `expired`, `staleHost` |
| 429 | `rateLimited` (with retry guidance when known) | | 429 | `rateLimited`, `capacityExceeded` (with retry guidance when known) |
| 503 | `serviceUnavailable` (with retry guidance when known) | | 503 | `serviceUnavailable` (with retry guidance when known) |
| 500 | `internalError` | | 500 | `internalError` |
+51 -8
View File
@@ -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.
+97
View File
@@ -0,0 +1,97 @@
# Diagnostic TestClient integration guide
Tracking: #25
`FinalFactory.Rendezvous.TestClient` is the smallest supported public-SDK consumer.
It exists for integration development, CI smoke checks, deployment verification,
and operator diagnosis. It is intentionally not a production game client, game
server, matchmaking UI, or relay.
The automated scenario matrix, privileged Linux namespace run, and topology
limitations are documented in the [deterministic topology harness](topology-harness.md).
## Prerequisites
Start a configured Rendezvous service and note both its HTTP base URL and UDP
mediator endpoint. The host needs a tenant-scoped publisher credential from the
deployment secret boundary. Put it in an environment variable and pass only that
variable's name when the default is unsuitable:
```bash
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<deployment-supplied value>'
```
Never put the credential in a command argument, URL, checked-in configuration,
shell trace, or captured test fixture. The development server's signing material
is process-ephemeral; credentials from a prior development process are invalid.
## Manual three-terminal flow
Start the host:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
--game space-game --environment development --region local --protocol 1
```
Browse from another terminal:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
browse --service http://127.0.0.1:5000/ \
--game space-game --environment development --region local --protocol 1
```
Join from a third terminal. Omit `--listing` for an interactive choice:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
join --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
--game space-game --environment development --region local --protocol 1 \
--listing 00000000-0000-0000-0000-000000000000
```
Replace the sample UUID with the public listing ID printed by host or browse.
Host and join each create one caller-owned LiteNetLib manager. That same socket
sends presence/punch traffic, establishes the authenticated direct connection,
and carries the ping/echo/ack/completion payload. The final completion confirms
that the host received the reliable acknowledgement; none of this traffic passes through the HTTP
service or UDP mediator.
## CI and deployment smoke flow
Use `--script --json`, set `--listing` when deterministic selection matters, and
check the documented process exit code. `--timeout-seconds` bounds each startup,
traversal, or direct-traffic stage; a script host also uses it as its total runtime
unless `--run-seconds` is explicit. A host can add `--exit-after-echo` so it
terminates after the joining peer acknowledges direct traffic and receives the
host's completion confirmation. Every wait is
bounded by coordinator state and `--timeout-seconds`; no orchestration should use
an unbounded sleep.
The normal test suite contains a real process gate that starts the built Server,
host TestClient, and join TestClient, waits for readiness and versioned events,
and verifies direct traffic, cleanup, JSON shape, and secret canaries. Process
trees are force-terminated in the test cleanup path if normal shutdown fails.
Useful success events are:
- `host.registered`, `host.ready`, `host.direct-traffic`, and `host.deregistered`;
- `browse.completed` and `browse.session`; and
- `join.connected`, `join.direct-traffic`, and `join.outcome-report`.
Failure events preserve stable typed phases and outcomes. When a terminal outcome
contains a configured dedicated endpoint, `join.fallback` reports `available`
with endpoint type `dedicated`; no raw address is printed and no fallback is
started implicitly.
## What the proof does and does not establish
The deterministic loopback test proves the complete service/host/client protocol,
ticket admission, and peer-to-peer payload path. Loopback is not evidence that all
consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths,
or platform policies permit hole punching. Same-LAN, separated observed endpoints,
network namespaces/containers, mediator restart, and adverse topology coverage
belong to the topology harness tracked by #14. Production rollout still requires
tests from representative networks and a game-owned fallback policy.
+91
View File
@@ -0,0 +1,91 @@
# Deterministic topology harness
Issue #14 is verified at three layers. The layers are deliberately separate so
the always-on gate remains deterministic while privileged CI workers can add a
stronger operating-system topology without overstating what local emulation
proves about the public Internet.
## Always-on public-process gate
`TestClientProcessIntegrationTests` launches the built server and the same
`FinalFactory.Rendezvous.TestClient` executable shipped to operators. Every
child process uses `--script --json`, dynamic HTTP and UDP ports, bounded
state-driven waits, and enforced process-tree cleanup.
The suite proves:
| Scenario | Required observation |
| --- | --- |
| Three-party happy path | register, presence-ready, browse, authorize, punch, authenticated LiteNetLib connection, direct ping/echo/ack/completion traffic, outcome report, disconnect, deregister |
| Same-LAN candidate | the connected peer is reported as `loopback` or `private`, never inferred merely from an introduction callback |
| Empty and missing selection | browse exits `11`; exact missing lookup exits `10` |
| Wrong tenant/protocol | no listing is returned for an incompatible protocol; exact joins with either mismatch fail before `join.punch` |
| Traversal timeout | an unreachable mediator produces typed `PunchTimedOut`, exits `12`, advertises the configured dedicated fallback, and never connects to it |
| Caller cancellation | POSIX `SIGINT` exits `130`, deregisters the listing, and removes it from public lookup |
| Abrupt host loss | the listing disappears after the presence window and before its lease expires; public exact lookup intentionally reports `NotFound` |
| Bounded host without a peer | exits `13` and still deregisters |
Captured output is parsed as the stable JSON v1 event schema. Publisher
credentials and signing-key material are checked against all captured output.
The direct traffic payload is handled only by the caller-owned host and client
LiteNetLib managers; the HTTP service and mediator do not implement or observe
the echo protocol.
Run the always-on scenarios with:
```bash
dotnet test Rendezvous.slnx --configuration Release --no-build \
--filter FullyQualifiedName~TestClientProcessIntegrationTests
```
## Deterministic protocol and adverse-state gate
The following real service-boundary tests cover conditions that a public CLI
cannot safely manufacture by accepting raw capabilities or tickets:
| Scenario | Test evidence |
| --- | --- |
| Same-NAT private candidates | `NatMediationProcessorTests.MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates` |
| Separate observed endpoints | `NatMediationProcessorTests.DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints` |
| One-time introduction and replay | `InMemoryEphemeralRendezvousStoreTests.AttemptCapabilitiesAndIntroductionAreOneTime` |
| Direct ticket replay | `RendezvousCoordinatorIntegrationTests.CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay` |
| Wrong tenant/protocol and stale presence | `InMemoryEphemeralRendezvousStoreTests.JoinRequiresExactScopeProtocolAndFreshHostPresence` |
| Cancellation and late callbacks | `RendezvousCoordinatorBehaviorTests.CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt` |
| Mediator restart | both cases of `UdpMediatorServiceTests.NativeLiteNetLibRequestsIntroduceTheAuthorizedPair`; the restarted case rebinds the same UDP port and completes a native LiteNetLib introduction |
These tests use fake monotonic clocks or state predicates where expiry and race
ordering matter. They do not use fixed sleeps as proof of state.
## Privileged Linux namespace gate
When a Linux CI worker can create network namespaces, the workflow sets
`RENDEZVOUS_RUN_NETNS_TESTS=1` and reruns
`PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints`.
The test creates a temporary WAN bridge, an isolated service namespace, two NAT
router namespaces, and isolated host/client LAN namespaces. Each NAT has its own
inside subnet and WAN address. Linux forwarding plus per-router MASQUERADE rules
force the service to observe separate translated endpoints; the public TestClient
processes must then complete authenticated direct traffic through those mappings
using the public candidate. Namespaces, rules, veth pairs, bridge, processes, and
sockets are removed in bounded async-disposal paths. A cleanup failure fails the
test.
If `ip netns add`/`iptables` is unavailable or the worker lacks `CAP_NET_ADMIN`,
CI records the limitation and keeps the always-on loopback suite as the required gate.
To request the privileged run explicitly:
```bash
RENDEZVOUS_RUN_NETNS_TESTS=1 dotnet test Rendezvous.slnx \
--configuration Release --no-build \
--filter FullyQualifiedName~PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints
```
## What this does not prove
Loopback, MASQUERADE, and namespace routing cannot reproduce every consumer router,
carrier-grade NAT, firewall, IPv6 transition mechanism, symmetric NAT mapping,
or real-world packet-loss pattern. The separate-observed-endpoint processor
test proves that untrusted private claims are excluded and public candidates are
selected; it is not presented as universal Internet traversal proof. Real
network canaries and measured production readiness remain the scope of issue
#23.
@@ -0,0 +1,142 @@
# Observability and operator runbook
This runbook defines the production signals and privileged controls for the
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
from the `FinalFactory.Rendezvous` meter and distributed-tracing activities from
`FinalFactory.Rendezvous.Server`. Connect those sources to the deployment's
OpenTelemetry or equivalent collector. Do not add identifiers to metric labels.
## Health and readiness
- `GET /health/live` proves that the HTTP process can answer. It deliberately
remains independent of provisioning, the state store, drain state, and optional
listeners so an orchestrator does not restart a recoverable dependency failure.
- `GET /health/ready` returns success only after the HTTP path is answering, the
required IPv4 UDP socket is bound, any configured IPv6 UDP socket is bound,
provisioning loaded successfully, the store is available, and drain has not
started. A failed check returns `503` and removes the instance from new work.
- A graceful drain immediately makes readiness fail while liveness remains healthy.
Existing work may complete until the bounded store drain deadline.
## Metrics and traces
| Instrument | Purpose | Bounded dimensions |
| --- | --- | --- |
| `rendezvous.http.requests` / `rendezvous.http.duration` | HTTP volume and latency | operation, status code |
| `rendezvous.udp.results` / `rendezvous.udp.duration` | UDP mediation volume and processing latency | frozen/litenet operation, result |
| `rendezvous.limiter.drops` | Requests shed by admission controls | transport, fixed partition class |
| `rendezvous.operator.authentication` | Accepted, forbidden, and rejected operator authentication | result |
| `rendezvous.audit.events` | Privileged action outcomes | fixed action, result |
| `rendezvous.connection.outcomes` | Client-reported direct-connect outcomes | normalized outcome, elapsed bucket |
| `rendezvous.pairing.latency` | Time from attempt creation to successful peer introduction | none |
| `rendezvous.queue.depth` | Active join-attempt queue depth | none |
| `rendezvous.store.active_listings` / `active_leases` / `active_attempts` / `replay_markers` | Current ephemeral load | none |
| `rendezvous.store.expiry_churn` | Cumulative natural expiry activity | none |
| `rendezvous.store.available` | Store health (`1` available, `0` unavailable) | none |
HTTP responses include `X-Rendezvous-Correlation-ID`. It is a generated trace ID
or random value, never a caller-supplied session or player identifier. UDP and
HTTP activities contain operation-level data only. Logs and traces must not add
tokens, capabilities, session/listing IDs, player subjects, metadata, raw IP
addresses, or endpoint values.
Recommended dashboard panels are request rate and p50/p95/p99 latency by fixed
operation, UDP result ratio, direct connection success ratio, pairing latency,
active listings/attempts, expiry churn, limiter drops, store availability,
operator authentication results, audit action results, and signing-key windows.
## Alerts
Tune thresholds from the normal production baseline, then keep these conditions
as distinct actionable alerts:
- **Signing key expiry:** page when any required signing key has less than seven
days before `signUntil`; escalate at 24 hours. Confirm a replacement is signing
and the previous key remains verify-only for the maximum credential lifetime.
- **Authentication spike:** warn when rejected or forbidden operator authentication
exceeds five attempts in five minutes. Treat unexpected publisher-authentication
growth as a possible credential or integration incident.
- **Direct success regression:** warn when the connected outcome ratio falls more
than 20% below its seven-day same-region baseline for 15 minutes, with a minimum
sample floor. Break down only by bounded outcome and time bucket.
- **Saturation:** warn when queue depth remains above 70% of the configured attempt
limit, limiter drops are sustained, or p95 latency exceeds the service objective;
page at 90% or when lease-critical traffic is shed.
- **Store degradation:** page immediately when `rendezvous.store.available` is zero
or readiness fails for the store. Rising expiry churn without corresponding new
work is a warning for stalled clients or clock/configuration mistakes.
- **Listener/config readiness:** page when no ready instances remain. Investigate
UDP bind failures, a configured-but-unbound IPv6 listener, provisioning errors,
and unintended drain state separately.
## Operator authentication and controls
Operator credentials use a signing key configured with `CredentialKinds:
["Operator"]`. Operator keys cannot be scoped to a game/environment or used for
publisher credentials. Mint short-lived operator credentials through the trusted
provisioning process, outside the public Rendezvous HTTP service, and grant only
the required permission. Never place credentials in command history, URLs, logs,
or support tickets.
The application also enforces a default-deny source boundary. Configure at most
32 exact operator source IPs in
`Rendezvous:AbuseProtection:OperatorAllowedAddresses`; an empty list disables all
operator HTTP access. Development permits loopback only. Production must place
`/v1/operator/*` behind a private management listener or reverse-proxy ACL, list
only the resulting trusted management source addresses, and block that path on
the public edge. If forwarded headers are enabled, keep the existing exact-proxy,
single-hop trust policy and allowlist the post-forwarding operator source. Verify
from both an allowed management host and a denied public host before deployment.
Denied sources are charged to the bounded general HTTP partition before credential
or request-body processing, then receive `404`; sustained denied traffic receives
the same typed `429` overload response as other public traffic.
Operator traffic has a dedicated, bounded rate/concurrency partition and critical
tracker-key reserve. Public browse/join saturation therefore cannot consume the
operator control budget, while compromised management sources remain rate-limited.
The OpenAPI document defines the separate `OperatorBearer` scheme. All endpoints
are under `/v1/operator`:
| Endpoint | Permission | Confirmation |
| --- | --- | --- |
| `GET /status` | `ReadPolicy` | none; returns aggregates, tenant status, safe key status, and audit counts |
| `POST /listings/revoke` | `RevokePublisher` | repeat the exact listing ID in `confirmListingId` |
| `POST /principals/revoke` | `RevokePublisher` | repeat the exact subject and choose a 1-600 second revocation lifetime |
| `POST /keys/revoke` | `RotateKeys` | repeat the exact key ID; runtime revocation is immediate |
| `POST /drain` | `ManagePolicy` | send the exact value `DRAIN` |
Publisher credentials are rejected on this surface even if their subject resembles
an operator. Destructive responses do not echo identifiers. The status response
does not expose player identities, raw endpoints, session metadata, capabilities,
or tokens. Every authenticated operator action, rejected confirmation, and
permission denial is audited with actor and target fingerprints.
Key revocation is process-local in the current single-instance store. Apply the
same revocation to every instance, then replace configuration before restarting;
a restart reconstructs the configured key ring. Principal revocation is bounded
to ten minutes and removes that principal's active listings and attempts. Use
listing revocation for one targeted session and drain before planned shutdown.
## Audit retention and incident handling
The in-process audit trail defaults to 10,000 entries and 30 days. It evicts the
oldest record at capacity and purges expired records on the next write. Configure
`Rendezvous:Audit:MaxEntries` and `RetentionDays` within their validated bounds.
Export the structured `AuditTrail` log events through the deployment's protected
logging pipeline when durable retention is required; the in-memory trail is not a
durable compliance archive. Those events include only timestamps, fixed action
fields, correlation IDs, and actor/target fingerprints.
Audit records retain timestamp, fixed action/result, target kind, correlation ID,
and 96-bit SHA-256 fingerprints of actor and target. Routine logs contain only the
fixed action/result/target kind and correlation ID. Restrict audit access to the
operator role, retain aggregates only as long as operationally necessary, and
delete raw exported audit data according to the 30-day policy unless an incident
hold is approved.
During an incident: confirm readiness and store health; capture aggregate graphs
and correlation IDs; revoke the narrowest listing, principal, or key; drain only
when isolation is required; record the action in the incident timeline; and verify
that direct success, limiter drops, and authentication rates return to baseline.
Do not copy player data, endpoints, or credentials into the incident record.
+103
View File
@@ -0,0 +1,103 @@
# Hostile-input and overload protection
Tracking: #15
Rendezvous treats every public HTTP request and UDP datagram as hostile. The
server applies bounded fixed-window request budgets and concurrency ceilings in
two stages so malformed input is discarded before expensive work while valid
traffic is also isolated by its authenticated scope.
## Enforcement order
1. Kestrel and the HTTP abuse middleware cap request bodies at 16 KiB. A known
oversized body receives a typed `413` response before endpoint dispatch.
2. Every HTTP request consumes global, source-prefix, and operation budgets and
acquires the corresponding concurrency leases. IPv4 sources share a `/24`
budget and IPv6 sources share a `/56` budget; raw addresses are not retained.
Non-lease operations also consume a smaller optional-work budget, leaving a
configured global and source-prefix reserve for renew, update, and delete
operations during shedding.
Health probes use their own source-prefix budget so public API overload cannot
make a healthy instance fail its orchestrator probes, while health traffic is
still bounded.
Operator endpoints likewise use a separate bounded rate/concurrency partition
backed by the critical tracker reserve. They first require an exact source IP
from the default-deny `OperatorAllowedAddresses` policy, so public traffic
cannot spend the incident-response budget.
3. Once an endpoint has safely derived identities, it also acquires applicable
tenant, principal or capability, and listing/attempt budgets. Secret
capabilities are represented only by bounded SHA-256 fingerprints.
4. Every UDP envelope consumes global, source-prefix, and wire-operation
budgets before decoding. A structurally and cryptographically valid request
then consumes capability, role, and mediation-handle budgets before state
mutation or introduction.
5. HTTP overload returns the stable `RateLimited` error, status `429`, and a
bounded `Retry-After` value in both the header and response contract. UDP
overload and every invalid UDP input are silently dropped.
The same HTTP identity budget is computed whether or not a listing or attempt
exists. Rejection therefore does not disclose resource existence. Publisher
authentication also completes before any tenant/resource operation, while the
pre-authentication source budget prevents invalid credentials from bypassing
load shedding.
## Bounded state and recovery
`Rendezvous:AbuseProtection:MaxTrackedKeys` is a hard combined ceiling for rate
and active-concurrency keys. General HTTP and UDP traffic cannot consume the
configured `CriticalTrackedKeyReserve`; lease operations, health probes, and
allowlisted operator controls may
use that reserve but never exceed the hard ceiling. A request that would exceed
its applicable ceiling fails closed without adding state. Fixed-window rate keys
are cleared at the next window boundary; concurrency keys are removed as their
request leases finish. HTTP and UDP trackers have separate locks and cardinality
partitions, so a UDP flood cannot block HTTP admission on a shared lock or
consume HTTP key capacity. This gives
deterministic burst recovery and prevents an attacker from growing a permanent
high-cardinality address, credential, or resource table.
The complete default profile is checked into
`src/FinalFactory.Rendezvous.Server/appsettings.json`. Operators may lower or
tune limits for a measured deployment profile, but must preserve all dimensions
and leave the tracker ceiling above the maximum simultaneous key set. A rolling
deployment should use the same profile on every instance. These per-process
limits are a final service boundary; an edge proxy may add stricter distributed
limits but is not a substitute for them.
When an HTTP reverse proxy is used, every immediate proxy address must be
allowlisted in `Rendezvous:AbuseProtection:TrustedProxyAddresses` (or indexed
environment variables such as
`Rendezvous__AbuseProtection__TrustedProxyAddresses__0`). Only one forwarded
hop is accepted. With an empty allowlist, forwarded headers are ignored and the
direct TCP peer is the source. Never add a broad network range or accept
untrusted `X-Forwarded-For` input: that would let a caller choose its own rate
partition.
## Reflection, disclosure, and logging rules
- UDP sends nothing for malformed, oversized, unauthenticated, stale,
replayed, wrong-role, or rate-limited input.
- Introductions are emitted only after both role-scoped capabilities bind to
their observed gameplay-socket sources. HTTP never supplies a public
introduction target.
- Private candidates must be same-family private unicast addresses and are used
only for peers observed behind the same public address.
- Abuse keys, exceptions, and responses never include bearer credentials,
capabilities, tickets, raw endpoints, metadata values, or hostile markup.
- Endpoint and capability values are not used as metric labels or log fields.
## Verification
The deterministic test corpora use the recorded seeds `0x152026`, `0x154A50`,
and `0x1557A7E`. They exercise 10,000 arbitrary UDP envelopes through the
production decoder, 5,000 arbitrary HTTP/credential parser inputs, and 1,000
mutated state transitions, including the oversized and configured-capacity
boundaries.
Focused tests cover IPv4 and IPv6 prefix
partitioning, tenant/principal/resource concurrency, tracker exhaustion,
window recovery, wire-operation isolation, a steady-state allocation ceiling,
typed `429`/`413` responses, secret fingerprint redaction, and silent
authenticated UDP shedding. The existing state, contract, HTTP, client,
and mediator suites continue to cover cross-tenant access, replay, role swaps,
credential rotation, bounded metadata, endpoint validation, and one-shot
amplification behavior.
+1 -1
View File
@@ -11,7 +11,7 @@ backlog where the control is implemented and verified.
| Per-game credentials and signing keys | Provisioned principals and versioned keys are scoped to game/environment; secrets come from a provider and never a public binary. (#5) | Cross-tenant authorization tests, rotation/overlap/revocation tests, and secret scans. | | Per-game credentials and signing keys | Provisioned principals and versioned keys are scoped to game/environment; secrets come from a provider and never a public binary. (#5) | Cross-tenant authorization tests, rotation/overlap/revocation tests, and secret scans. |
| Short-lived, single-purpose tokens resistant to replay | Issuer fixes audience, tenant, attempt, role, issued/expiry times, nonce, and key ID; store atomically consumes nonce/ticket. (#4, #6, #10) | Golden vectors; expired, future, mutated, wrong-role, wrong-tenant, and concurrent replay tests. | | Short-lived, single-purpose tokens resistant to replay | Issuer fixes audience, tenant, attempt, role, issued/expiry times, nonce, and key ID; store atomically consumes nonce/ticket. (#4, #6, #10) | Golden vectors; expired, future, mutated, wrong-role, wrong-tenant, and concurrent replay tests. |
| Strict payload, metadata, and token size limits | ADR 0003 ceilings are checked before allocation/deserialization and again at domain construction. (#4, #15) | Boundary/property tests, malformed corpus, and allocation-aware fuzzing. | | Strict payload, metadata, and token size limits | ADR 0003 ceilings are checked before allocation/deserialization and again at domain construction. (#4, #15) | Boundary/property tests, malformed corpus, and allocation-aware fuzzing. |
| Registration, query, and introduction rate limits | Layered per-address, principal, tenant, and global token buckets with bounded queues and stable retry guidance. (#15) | Limit partition/isolation tests and overload/soak profiles. | | Registration, query, and introduction rate limits | Layered fixed-window budgets and concurrency leases cover global, operation, IPv4 `/24` or IPv6 `/56`, tenant, principal/capability, and listing/attempt dimensions with a bounded key table and stable retry guidance. (#15) | Deterministic partition, concurrency, tracker-exhaustion, recovery, typed-overload, and silent-UDP-shedding tests. |
| Lease expiry removes abandoned servers | Visibility and join eligibility atomically require a fresh lease and fresh authenticated presence. (#6, #7) | Fake-clock expiry, renew/expire race, restart, and stale-host join tests. | | Lease expiry removes abandoned servers | Visibility and join eligibility atomically require a fresh lease and fresh authenticated presence. (#6, #7) | Fake-clock expiry, renew/expire race, restart, and stale-host join tests. |
| Validate game, environment, room, and protocol boundaries | Every identifier is a validated type; store keys and authorization decisions include server-derived tenant scope; protocol is exact-match in v1. (#4-#10) | Contract, tenant-isolation, incompatible-version, and confused-deputy tests. | | Validate game, environment, room, and protocol boundaries | Every identifier is a validated type; store keys and authorization decisions include server-derived tenant scope; protocol is exact-match in v1. (#4-#10) | Contract, tenant-isolation, incompatible-version, and confused-deputy tests. |
| Structured audit events without secrets or reusable credentials | Allowlisted audit schema excludes metadata values, raw endpoints, tokens, and key material; event volume is bounded. (#16) | Captured-log/audit assertions and credential canary scans. | | Structured audit events without secrets or reusable credentials | Allowlisted audit schema excludes metadata values, raw endpoints, tokens, and key material; event volume is bounded. (#16) | Captured-log/audit assertions and credential canary scans. |
@@ -0,0 +1,183 @@
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousConnectionOutcomeSource
{
RendezvousService = 1,
LocalTraversal = 2,
RemoteHost = 3,
Caller = 4,
Lifecycle = 5,
}
public enum RendezvousConnectionFailureCategory
{
None = 0,
Directory = 1,
Compatibility = 2,
Authorization = 3,
Capacity = 4,
HostPresence = 5,
Service = 6,
Mediation = 7,
NatTraversal = 8,
DirectConnection = 9,
Lifecycle = 10,
}
public enum RendezvousConnectionPhase
{
Directory = 1,
Authorization = 2,
Mediation = 3,
NatTraversal = 4,
DirectConnection = 5,
Complete = 6,
}
public sealed class RendezvousConnectionOutcome
{
private readonly NetworkEndpoint? _dedicatedFallback;
private RendezvousConnectionOutcome(
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
TimeSpan elapsed,
RendezvousErrorCode? serviceError,
NetworkEndpoint? dedicatedFallback,
NetPeer? peer)
{
if (elapsed < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(elapsed));
}
if (dedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
{
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
}
Kind = kind;
Source = source;
Category = category;
Phase = phase;
Elapsed = elapsed;
ServiceError = serviceError;
_dedicatedFallback = RendezvousEndpoint.Copy(dedicatedFallback);
Peer = peer;
}
public ConnectionOutcomeKind Kind { get; }
public RendezvousConnectionOutcomeSource Source { get; }
public RendezvousConnectionFailureCategory Category { get; }
public RendezvousConnectionPhase Phase { get; }
public TimeSpan Elapsed { get; }
public RendezvousErrorCode? ServiceError { get; }
public NetworkEndpoint? DedicatedFallback => RendezvousEndpoint.Copy(_dedicatedFallback);
public NetPeer? Peer { get; }
public bool IsSuccess => Kind == ConnectionOutcomeKind.Connected;
public bool HasDedicatedFallback => _dedicatedFallback is not null;
public static RendezvousConnectionOutcome FromServiceError(
RendezvousErrorCode error,
TimeSpan elapsed,
NetworkEndpoint? dedicatedFallback = null)
{
if (error == RendezvousErrorCode.None)
{
throw new ArgumentException("A service failure outcome requires an error.", nameof(error));
}
(ConnectionOutcomeKind kind, RendezvousConnectionFailureCategory category, RendezvousConnectionPhase phase) =
error switch
{
RendezvousErrorCode.NotFound => (
ConnectionOutcomeKind.DirectoryNotFound,
RendezvousConnectionFailureCategory.Directory,
RendezvousConnectionPhase.Directory),
RendezvousErrorCode.Expired => (
ConnectionOutcomeKind.AttemptExpired,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization),
RendezvousErrorCode.IncompatibleProtocol => (
ConnectionOutcomeKind.IncompatibleProtocol,
RendezvousConnectionFailureCategory.Compatibility,
RendezvousConnectionPhase.Directory),
RendezvousErrorCode.AuthenticationRequired
or RendezvousErrorCode.Forbidden
or RendezvousErrorCode.ReplayRejected => (
ConnectionOutcomeKind.Unauthorized,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization),
RendezvousErrorCode.RateLimited
or RendezvousErrorCode.CapacityExceeded => (
ConnectionOutcomeKind.RateLimited,
RendezvousConnectionFailureCategory.Capacity,
RendezvousConnectionPhase.Authorization),
RendezvousErrorCode.StaleHost => (
ConnectionOutcomeKind.NoHostPresence,
RendezvousConnectionFailureCategory.HostPresence,
RendezvousConnectionPhase.Mediation),
RendezvousErrorCode.ServiceUnavailable => (
ConnectionOutcomeKind.ServiceUnavailable,
RendezvousConnectionFailureCategory.Service,
RendezvousConnectionPhase.Authorization),
_ => (
ConnectionOutcomeKind.ServiceRejected,
RendezvousConnectionFailureCategory.Service,
RendezvousConnectionPhase.Authorization),
};
return new(
kind,
RendezvousConnectionOutcomeSource.RendezvousService,
category,
phase,
elapsed,
error,
dedicatedFallback,
null);
}
public static ConnectionElapsedBucket BucketElapsed(TimeSpan elapsed)
{
if (elapsed < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(elapsed));
}
return elapsed.TotalSeconds switch
{
< 1 => ConnectionElapsedBucket.UnderOneSecond,
< 5 => ConnectionElapsedBucket.OneToFiveSeconds,
< 15 => ConnectionElapsedBucket.FiveToFifteenSeconds,
< 30 => ConnectionElapsedBucket.FifteenToThirtySeconds,
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
};
}
public override string ToString() =>
$"[RendezvousConnectionOutcome {Kind}; {Source}; credentials redacted]";
internal static RendezvousConnectionOutcome Create(
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
TimeSpan elapsed,
NetworkEndpoint? dedicatedFallback = null,
NetPeer? peer = null) => new(
kind,
source,
category,
phase,
elapsed,
null,
dedicatedFallback,
peer);
}
@@ -0,0 +1,35 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousConnectionStartResult
{
internal RendezvousConnectionStartResult(
CreateJoinAttemptResponse? attempt,
RendezvousConnectionOutcome? outcome)
{
if ((attempt is null) == (outcome is null))
{
throw new ArgumentException(
"A connection start result requires exactly one attempt or terminal outcome.");
}
Attempt = attempt;
Outcome = outcome;
}
public CreateJoinAttemptResponse? Attempt { get; }
public RendezvousConnectionOutcome? Outcome { get; }
public bool IsReadyForTraversal => Attempt is not null;
public bool IsCompleted => Outcome is not null;
public static RendezvousConnectionStartResult ReadyForTraversal(
CreateJoinAttemptResponse attempt) => new(
attempt ?? throw new ArgumentNullException(nameof(attempt)),
null);
public static RendezvousConnectionStartResult Completed(
RendezvousConnectionOutcome outcome) => new(
null,
outcome ?? throw new ArgumentNullException(nameof(outcome)));
}
@@ -0,0 +1,236 @@
using System.Diagnostics;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousJoinClient : IRendezvousJoinClient
{
private const string LeaseTokenHeader = "X-Rendezvous-Lease-Token";
private const string ClientPunchCapabilityHeader = "X-Rendezvous-Client-Punch-Capability";
private readonly RendezvousHttpTransport _transport;
public RendezvousJoinClient(
HttpClient httpClient,
RendezvousClientOptions? options = null,
IRendezvousDelay? delay = null)
{
_transport = new(httpClient, options, delay);
}
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
CreateJoinAttemptRequest body = new()
{
ContractVersion = request.ContractVersion,
IdempotencyKey = request.IdempotencyKey,
GameId = request.GameId,
EnvironmentId = request.EnvironmentId,
ListingId = request.ListingId,
ProtocolVersion = request.ProtocolVersion,
};
return _transport.SendSafeAsync<CreateJoinAttemptResponse>(
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body),
cancellationToken);
}
public async Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default)
{
if (dedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
{
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
}
Stopwatch elapsed = Stopwatch.StartNew();
try
{
RendezvousClientResult<CreateJoinAttemptResponse> result = await CreateAsync(
request,
cancellationToken).ConfigureAwait(false);
elapsed.Stop();
return result.IsSuccess && result.Value is not null
? RendezvousConnectionStartResult.ReadyForTraversal(result.Value)
: RendezvousConnectionStartResult.Completed(
RendezvousConnectionOutcome.FromServiceError(
result.Error,
elapsed.Elapsed,
dedicatedFallback));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
elapsed.Stop();
return RendezvousConnectionStartResult.Completed(
RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Authorization,
elapsed.Elapsed,
dedicatedFallback));
}
}
public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default)
{
if (attempt is null)
{
throw new ArgumentNullException(nameof(attempt));
}
return _transport.SendSafeAsync<bool>(
() => HeaderRequest(
HttpMethod.Delete,
$"v1/join-attempts/{attempt.AttemptId}",
ClientPunchCapabilityHeader,
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))),
cancellationToken);
}
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
if (pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
string query = $"v1/sessions/{session.ListingId}/join-attempts"
+ $"?contractVersion={ContractLimits.ContractVersion}"
+ $"&pageSize={pageSize}"
+ (cursor is null ? string.Empty : $"&cursor={Uri.EscapeDataString(cursor)}");
return _transport.SendSafeAsync<BrowseHostJoinAttemptsResponse>(
() => HeaderRequest(
HttpMethod.Get,
query,
LeaseTokenHeader,
RequireHeaderValue(session.LeaseToken, nameof(session))),
cancellationToken);
}
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
if (maximumPages is < 1 or > 1_000)
{
throw new ArgumentOutOfRangeException(nameof(maximumPages));
}
List<HostJoinAttempt> attempts = [];
string? cursor = null;
for (int page = 0; page < maximumPages; page++)
{
RendezvousClientResult<BrowseHostJoinAttemptsResponse> result =
await BrowseForHostAsync(
session,
ContractLimits.BrowserPageMaxItems,
cursor,
cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
attempts.AddRange(result.Value.Items);
cursor = result.Value.NextCursor;
if (string.IsNullOrEmpty(cursor))
{
return RendezvousClientResult.Success<IReadOnlyList<HostJoinAttempt>>(
attempts.AsReadOnly());
}
}
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
RendezvousErrorCode.CapacityExceeded,
$"Host invitation polling exceeded the configured {maximumPages}-page limit.");
}
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default)
{
if (attempt is null)
{
throw new ArgumentNullException(nameof(attempt));
}
if (outcome is null)
{
throw new ArgumentNullException(nameof(outcome));
}
if (!ContractValidation.IsReportableConnectionOutcome(outcome.Kind))
{
throw new ArgumentException(
"This outcome cannot be reported for an issued join attempt.",
nameof(outcome));
}
ReportConnectionOutcomeRequest body = new()
{
Outcome = outcome.Kind,
ElapsedBucket = RendezvousConnectionOutcome.BucketElapsed(outcome.Elapsed),
};
return _transport.SendSafeAsync<ReportConnectionOutcomeResponse>(
() => HeaderJsonRequest(
HttpMethod.Post,
$"v1/join-attempts/{attempt.AttemptId}/outcome",
ClientPunchCapabilityHeader,
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt)),
body),
cancellationToken);
}
private static HttpRequestMessage HeaderRequest(
HttpMethod method,
string uri,
string header,
string value)
{
HttpRequestMessage request = new(method, uri);
request.Headers.TryAddWithoutValidation(header, value);
return request;
}
private static HttpRequestMessage HeaderJsonRequest<T>(
HttpMethod method,
string uri,
string header,
string value,
T body)
{
HttpRequestMessage request = RendezvousHttpTransport.JsonRequest(method, uri, body);
request.Headers.TryAddWithoutValidation(header, value);
return request;
}
private static string RequireHeaderValue(string value, string parameterName) =>
!string.IsNullOrWhiteSpace(value)
? value
: throw new ArgumentException("The required capability is missing.", parameterName);
}
+121 -3
View File
@@ -1,6 +1,6 @@
# FinalFactory.Rendezvous.Client # FinalFactory.Rendezvous.Client
Godot-independent .NET publisher and session-browser SDK for Rendezvous v1. Godot-independent .NET publisher, browser, join, and LiteNetLib traversal SDK for Rendezvous v1.
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`. The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
```csharp ```csharp
@@ -29,6 +29,12 @@ RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAs
DisplayName = "My server", DisplayName = "My server",
Visibility = ListingVisibility.Public, Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 }, Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.40",
Port = 7777,
},
}, },
publisherCredential, publisherCredential,
cancellationToken); cancellationToken);
@@ -42,6 +48,117 @@ 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);
```
For direct connections, let the SDK drive those tokens from the same caller-owned
LiteNetLib socket that carries gameplay. Ask the routing listener to create the
bound manager, then configure and start that caller-owned manager yourself. The
factory does not open a socket, and synchronized events must remain enabled:
```csharp
RendezvousNetListener networkEvents = new();
NetManager gameplayNetManager = networkEvents.CreateManager();
if (!gameplayNetManager.Start(0))
{
throw new InvalidOperationException("The gameplay UDP socket could not start.");
}
```
The host polls join invitations asynchronously; that method only queues a
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
frame on the thread that owns the manager:
```csharp
RendezvousJoinClient joins = new(http);
using RendezvousHostCoordinator host = new(
gameplayNetManager,
networkEvents,
mediatorEndPoint,
session,
joins);
// Run periodically from the game's normal async scheduling path.
await host.RefreshJoinAttemptsAsync(cancellationToken);
// Godot _Process, Update, or the equivalent main-thread frame callback.
host.Poll();
```
Do not also call `gameplayNetManager.PollEvents()` or
`gameplayNetManager.NatPunchModule.PollEvents()` when a coordinator owns polling.
The host coordinator refreshes host presence, punches for queued invitations,
validates the introduction ticket, and accepts the direct request. Subscribe to
`AttemptCompleted`; a `Connected` result is raised only after LiteNetLib reports
the accepted peer as connected. Register ordinary gameplay callbacks on
`networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct
requests for ticket validation and forwards every other callback normally.
The joining game first requests an attempt through the typed start API. It returns
exactly one issued attempt or one terminal service outcome, so service authority
is not confused with a later locally observed traversal failure:
```csharp
RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync(
createJoinRequest,
cancellationToken: cancellationToken);
if (start.Outcome is { } serviceOutcome)
{
ShowConnectionFailure(serviceOutcome.Kind, serviceOutcome.Category);
return;
}
CreateJoinAttemptResponse attempt = start.Attempt
?? throw new InvalidOperationException("The typed start result was invalid.");
using RendezvousClientCoordinator client = new(
gameplayNetManager,
networkEvents,
mediatorEndPoint,
attempt);
// Godot _Process, Update, or the equivalent main-thread frame callback.
client.Poll();
```
NAT introduction changes the client state to `Connecting`; it is not success.
Only a `Connected` outcome supplies `Peer`. Completion exposes a stable kind,
source, category, phase, and elapsed duration. The default HTTP silence, punch,
and direct-connect budgets are five, ten, and five seconds respectively; configure
them through `RendezvousClientOptions` and `RendezvousCoordinatorOptions` when a
game has measured reasons to do so. The signed attempt expiry is always the
absolute upper bound.
Call `Cancel()` and then `Poll()` for local cancellation, or
`CancelAsync(joins, cancellationToken)` to also revoke the service attempt.
Terminal client paths complete exactly once and release all event subscriptions,
so late packets and callbacks are inert. Disposing a coordinator never stops or
disposes the caller-owned manager and does not touch an in-flight peer; call
`Cancel()` followed by `Poll()` first when that peer must also be disconnected.
After terminal completion, reporting is explicit and safe to retry. It sends only
the authenticated outcome enum and a coarse elapsed bucket—never the endpoint,
exact duration, diagnostic text, metadata, or player identity:
```csharp
RendezvousClientResult<ReportConnectionOutcomeResponse> report =
await client.ReportOutcomeAsync(joins, cancellationToken);
```
An optional `DedicatedFallback` is copied from the authoritative listing into the
issued attempt and terminal outcome. A local deployment may replace it with
`RendezvousCoordinatorOptions.DedicatedFallbackOverride`. The SDK only returns
the endpoint; it never connects automatically. The game must explicitly decide
whether to use it and then connect and authenticate through its own gameplay
transport. If the outcome has no fallback, v1 offers no relay.
Lease renewal is explicit and caller-controlled: Lease renewal is explicit and caller-controlled:
@@ -75,5 +192,6 @@ apply its own player identity, capacity, ban, and gameplay admission rules. Revo
the attempt on cancellation and dispose the validator during host shutdown so its the attempt on cancellation and dispose the validator during host shutdown so its
keyed ticket digests are zeroed. keyed ticket digests are zeroed.
See the repository's ADR 0007 for HTTP ownership/retry semantics and ADR 0008 for See the repository's ADR 0007 for HTTP ownership/retry semantics, ADR 0008 for
join-capability and connection-ticket security semantics. join-capability and connection-ticket security semantics, and ADR 0010 for typed
outcomes, deadlines, reporting, and caller-owned fallback.
@@ -42,6 +42,10 @@ public static class RendezvousClientResult
public sealed class PublishedSession public sealed class PublishedSession
{ {
private readonly object _timingGate = new();
private DateTimeOffset _expiresAt;
private int _leaseRenewAfterSeconds;
internal PublishedSession(RegisterSessionResponse response) internal PublishedSession(RegisterSessionResponse response)
{ {
ListingId = response.ListingId; ListingId = response.ListingId;
@@ -49,8 +53,8 @@ public sealed class PublishedSession
LeaseToken = response.LeaseToken; LeaseToken = response.LeaseToken;
HostPresenceHandle = response.HostPresenceHandle; HostPresenceHandle = response.HostPresenceHandle;
HostPresenceCapability = response.HostPresenceCapability; HostPresenceCapability = response.HostPresenceCapability;
ExpiresAt = response.ExpiresAt; _expiresAt = response.ExpiresAt;
LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds; _leaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds; HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
} }
@@ -59,8 +63,41 @@ public sealed class PublishedSession
public string LeaseToken { get; } public string LeaseToken { get; }
public MediationHandle HostPresenceHandle { get; } public MediationHandle HostPresenceHandle { get; }
public string HostPresenceCapability { get; } public string HostPresenceCapability { get; }
public DateTimeOffset ExpiresAt { get; internal set; } public DateTimeOffset ExpiresAt
public int LeaseRenewAfterSeconds { get; internal set; } {
get
{
lock (_timingGate)
{
return _expiresAt;
}
}
internal set
{
lock (_timingGate)
{
_expiresAt = value;
}
}
}
public int LeaseRenewAfterSeconds
{
get
{
lock (_timingGate)
{
return _leaseRenewAfterSeconds;
}
}
internal set
{
lock (_timingGate)
{
_leaseRenewAfterSeconds = value;
}
}
}
public int HostPresenceRefreshAfterSeconds { get; } public int HostPresenceRefreshAfterSeconds { get; }
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]"; public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
@@ -109,6 +146,38 @@ public interface IRendezvousSessionBrowserClient
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }
public interface IRendezvousJoinClient
{
Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default);
}
public interface IRendezvousDelay public interface IRendezvousDelay
{ {
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken); Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
@@ -117,6 +186,7 @@ public interface IRendezvousDelay
public sealed class RendezvousClientOptions public sealed class RendezvousClientOptions
{ {
public int MaximumSafeRetries { get; set; } = 2; public int MaximumSafeRetries { get; set; } = 2;
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200); public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2); public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
public double JitterRatio { get; set; } = 0.2; public double JitterRatio { get; set; } = 0.2;
@@ -124,6 +194,8 @@ public sealed class RendezvousClientOptions
internal void Validate() internal void Validate()
{ {
if (MaximumSafeRetries is < 0 or > 5 if (MaximumSafeRetries is < 0 or > 5
|| RequestTimeout <= TimeSpan.Zero
|| RequestTimeout > TimeSpan.FromSeconds(30)
|| InitialRetryDelay < TimeSpan.Zero || InitialRetryDelay < TimeSpan.Zero
|| MaximumRetryDelay < InitialRetryDelay || MaximumRetryDelay < InitialRetryDelay
|| MaximumRetryDelay > TimeSpan.FromSeconds(30) || MaximumRetryDelay > TimeSpan.FromSeconds(30)
@@ -139,3 +211,15 @@ internal sealed class SystemRendezvousDelay : IRendezvousDelay
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) => public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
Task.Delay(delay, cancellationToken); Task.Delay(delay, cancellationToken);
} }
internal static class RendezvousEndpoint
{
internal static NetworkEndpoint? Copy(NetworkEndpoint? endpoint) => endpoint is null
? null
: new NetworkEndpoint
{
AddressFamily = endpoint.AddressFamily,
Address = endpoint.Address,
Port = endpoint.Port,
};
}
@@ -24,6 +24,7 @@ internal sealed class RendezvousHttpTransport
_options = new RendezvousClientOptions _options = new RendezvousClientOptions
{ {
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries, MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
RequestTimeout = suppliedOptions.RequestTimeout,
InitialRetryDelay = suppliedOptions.InitialRetryDelay, InitialRetryDelay = suppliedOptions.InitialRetryDelay,
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay, MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
JitterRatio = suppliedOptions.JitterRatio, JitterRatio = suppliedOptions.JitterRatio,
@@ -38,11 +39,15 @@ internal sealed class RendezvousHttpTransport
for (int attempt = 0; ; attempt++) for (int attempt = 0; ; attempt++)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
using CancellationTokenSource requestTimeout =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
requestTimeout.CancelAfter(_options.RequestTimeout);
CancellationToken requestCancellation = requestTimeout.Token;
try try
{ {
using HttpRequestMessage request = requestFactory(); using HttpRequestMessage request = requestFactory();
using HttpResponseMessage response = await _httpClient using HttpResponseMessage response = await _httpClient
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestCancellation)
.ConfigureAwait(false); .ConfigureAwait(false);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
@@ -54,7 +59,7 @@ internal sealed class RendezvousHttpTransport
byte[] payload; byte[] payload;
try try
{ {
payload = await ReadBoundedAsync(response.Content, cancellationToken) payload = await ReadBoundedAsync(response.Content, requestCancellation)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (InvalidDataException) catch (InvalidDataException)
@@ -81,7 +86,7 @@ internal sealed class RendezvousHttpTransport
: RendezvousClientResult.Success(value); : RendezvousClientResult.Success(value);
} }
ApiError error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false); ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter); int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code)) if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
{ {
@@ -88,6 +88,7 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
DisplayName = request.DisplayName, DisplayName = request.DisplayName,
Capacity = CopyCapacity(request.Capacity), Capacity = CopyCapacity(request.Capacity),
Metadata = CopyMetadata(request.Metadata), Metadata = CopyMetadata(request.Metadata),
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
}; };
return _transport.SendSafeAsync<bool>( return _transport.SendSafeAsync<bool>(
() => RendezvousHttpTransport.JsonRequest( () => RendezvousHttpTransport.JsonRequest(
@@ -138,6 +139,7 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
Visibility = request.Visibility, Visibility = request.Visibility,
Capacity = CopyCapacity(request.Capacity), Capacity = CopyCapacity(request.Capacity),
Metadata = CopyMetadata(request.Metadata), Metadata = CopyMetadata(request.Metadata),
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
}; };
private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new() private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new()
@@ -148,4 +150,5 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) => private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) =>
new(metadata, StringComparer.Ordinal); new(metadata, StringComparer.Ordinal);
} }
@@ -0,0 +1,83 @@
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class DirectConnectionRequest
{
public JoinAttemptId AttemptId { get; set; }
public string ConnectionTicket { get; set; } = string.Empty;
public override string ToString() =>
$"[DirectConnectionRequest {AttemptId}; ticket redacted]";
}
public static class DirectConnectionRequestCodec
{
public const int EncodedLength = 63;
private const int MagicLength = 4;
private const int AttemptIdLength = 16;
private const int TicketLength = ContractLimits.DerivedCredentialCharacters;
private static readonly byte[] Magic = [(byte)'R', (byte)'V', (byte)'D', (byte)'1'];
public static bool IsRendezvousRequest(ReadOnlySpan<byte> encoded) =>
encoded.Length >= MagicLength && encoded[..MagicLength].SequenceEqual(Magic);
public static byte[] Encode(JoinAttemptId attemptId, string connectionTicket)
{
if (attemptId.Value == Guid.Empty
|| connectionTicket is null
|| connectionTicket.Length != TicketLength
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
{
throw new ArgumentException("The direct connection request fields are invalid.");
}
byte[] encoded = new byte[EncodedLength];
Magic.CopyTo(encoded, 0);
if (!attemptId.Value.TryWriteBytes(encoded.AsSpan(MagicLength, AttemptIdLength)))
{
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
}
Encoding.ASCII.GetBytes(
connectionTicket,
0,
connectionTicket.Length,
encoded,
MagicLength + AttemptIdLength);
return encoded;
}
public static bool TryDecode(
ReadOnlySpan<byte> encoded,
out DirectConnectionRequest? request)
{
request = null;
if (encoded.Length != EncodedLength
|| !encoded[..MagicLength].SequenceEqual(Magic))
{
return false;
}
Guid attemptId = new(encoded.Slice(MagicLength, AttemptIdLength));
if (attemptId == Guid.Empty)
{
return false;
}
string ticket = Encoding.ASCII.GetString(encoded[(MagicLength + AttemptIdLength)..]);
if (!ContractValidation.IsConnectionTicketValid(ticket))
{
return false;
}
request = new DirectConnectionRequest
{
AttemptId = new JoinAttemptId(attemptId),
ConnectionTicket = ticket,
};
return true;
}
}
@@ -0,0 +1,462 @@
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousClientCoordinator : IDisposable
{
private readonly NetManager _manager;
private readonly RendezvousNetListener _networkEvents;
private readonly EventBasedNatPunchListener _punchEvents;
private readonly IPEndPoint _mediator;
private readonly CreateJoinAttemptResponse _attempt;
private readonly IRendezvousCoordinatorClock _clock;
private readonly RendezvousCoordinatorOptions _options;
private readonly RendezvousPunchRetrySchedule _retry;
private readonly object _completionGate = new();
private readonly TimeSpan _startedAt;
private readonly TimeSpan _attemptDeadline;
private readonly TimeSpan _punchDeadline;
private readonly NetworkEndpoint? _dedicatedFallback;
private NetPeer? _connectingPeer;
private IPEndPoint? _directEndpoint;
private TimeSpan? _directDeadline;
private RendezvousConnectionOutcome? _outcome;
private bool _cancelRequested;
private int _polling;
private bool _subscriptionsReleased;
private int _disposed;
public RendezvousClientCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
CreateJoinAttemptResponse attempt,
RendezvousCoordinatorOptions? options = null)
: this(
manager,
networkEvents,
mediator,
attempt,
options,
new SystemRendezvousCoordinatorClock())
{
}
internal RendezvousClientCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
CreateJoinAttemptResponse attempt,
RendezvousCoordinatorOptions? options,
IRendezvousCoordinatorClock clock)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
_punchEvents = _networkEvents.PunchEvents;
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_attempt = attempt ?? throw new ArgumentNullException(nameof(attempt));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_options = (options ?? new RendezvousCoordinatorOptions())
.CopyAndValidate();
_retry = new(_options, _clock);
RendezvousManagerGuard.Validate(_manager, _networkEvents);
DateTimeOffset startedUtc = _clock.UtcNow;
if (_mediator.Port is < 1 or > 65_535
|| _attempt.AttemptId.Value == Guid.Empty
|| _attempt.MediationHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability)
|| !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest)
|| _attempt.ExpiresAt <= startedUtc)
{
throw new ArgumentException("The client traversal inputs are invalid.");
}
_startedAt = _clock.Elapsed;
_attemptDeadline = _startedAt + (_attempt.ExpiresAt - startedUtc);
_punchDeadline = Min(_attemptDeadline, _startedAt + _options.PunchTimeout);
_dedicatedFallback = RendezvousEndpoint.Copy(
_options.DedicatedFallbackOverride ?? _attempt.DedicatedFallback);
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
_networkEvents.RendezvousNetworkError += OnNetworkError;
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
}
public event EventHandler<RendezvousConnectionCompletedEventArgs>? Completed;
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
public NetPeer? ConnectedPeer { get; private set; }
public RendezvousConnectionOutcome? Outcome => Volatile.Read(ref _outcome);
public bool IsCompleted => Outcome is not null;
public void Cancel() => Volatile.Write(ref _cancelRequested, true);
public async Task<RendezvousClientResult<bool>> CancelAsync(
IRendezvousJoinClient joinClient,
CancellationToken cancellationToken = default)
{
if (joinClient is null)
{
throw new ArgumentNullException(nameof(joinClient));
}
ThrowIfDisposed();
Cancel();
return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false);
}
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
IRendezvousJoinClient joinClient,
CancellationToken cancellationToken = default)
{
if (joinClient is null)
{
throw new ArgumentNullException(nameof(joinClient));
}
ThrowIfDisposed();
if (Outcome is null)
{
throw new InvalidOperationException("The connection attempt has not completed.");
}
return joinClient.ReportOutcomeAsync(_attempt, Outcome, cancellationToken);
}
public void Poll()
{
ThrowIfDisposed();
if (IsCompleted)
{
return;
}
if (Interlocked.Exchange(ref _polling, 1) != 0)
{
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
}
try
{
if (Volatile.Read(ref _cancelRequested))
{
DisconnectPendingPeer();
Complete(
RendezvousConnectionState.Cancelled,
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
return;
}
if (!_manager.IsRunning)
{
CompleteManagerStopped();
return;
}
_manager.PollEvents();
_manager.NatPunchModule.PollEvents();
if (IsCompleted)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
TimeSpan elapsed = _clock.Elapsed;
if (Volatile.Read(ref _cancelRequested))
{
DisconnectPendingPeer();
Complete(
RendezvousConnectionState.Cancelled,
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
}
else if (!_manager.IsRunning)
{
CompleteManagerStopped();
}
else if (now >= _attempt.ExpiresAt || elapsed >= _attemptDeadline)
{
DisconnectPendingPeer();
Complete(
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.AttemptExpired,
RendezvousConnectionOutcomeSource.RendezvousService,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization);
}
else if (State == RendezvousConnectionState.Punching)
{
if (elapsed >= _punchDeadline
|| _retry.IsExhausted && _retry.IsDue(elapsed))
{
Complete(
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.PunchTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.NatTraversal,
RendezvousConnectionPhase.NatTraversal);
return;
}
if (_retry.IsDue(elapsed))
{
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Client,
_attempt.MediationHandle,
_attempt.ClientPunchCapability));
_retry.RecordRequest();
}
}
else if (State == RendezvousConnectionState.Connecting
&& _directDeadline is TimeSpan directDeadline
&& directDeadline <= elapsed)
{
DisconnectPendingPeer();
Complete(
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.DirectConnectTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
}
}
finally
{
Volatile.Write(ref _polling, 0);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
if (!IsCompleted)
{
Complete(
RendezvousConnectionState.Disposed,
ConnectionOutcomeKind.Disposed,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
}
ReleaseSubscriptions();
}
public override string ToString() =>
$"[RendezvousClientCoordinator {_attempt.AttemptId}; credentials redacted]";
private void OnNatIntroductionSuccess(
IPEndPoint target,
NatAddressType addressType,
string encodedIntroduction)
{
_ = addressType;
if (State != RendezvousConnectionState.Punching
|| !NatIntroductionTokenCodec.TryDecode(
encodedIntroduction,
out NatIntroductionToken? introduction)
|| introduction is null
|| introduction.AttemptId != _attempt.AttemptId
|| !NatIntroductionTokenCodec.MatchesDigest(
introduction.ConnectionTicket,
_attempt.ConnectionTicketDigest))
{
return;
}
byte[] connectionData = DirectConnectionRequestCodec.Encode(
introduction.AttemptId,
introduction.ConnectionTicket);
_directEndpoint = target;
_connectingPeer = _manager.Connect(target, connectionData);
if (_connectingPeer is null
|| _connectingPeer.ConnectionState != ConnectionState.Outgoing)
{
_connectingPeer = null;
Complete(
RendezvousConnectionState.Rejected,
ConnectionOutcomeKind.TransportError,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
return;
}
State = RendezvousConnectionState.Connecting;
_directDeadline = Min(
_attemptDeadline,
_clock.Elapsed + _options.DirectConnectTimeout);
}
private void OnPeerConnected(NetPeer peer)
{
if (State != RendezvousConnectionState.Connecting
|| !ReferenceEquals(peer, _connectingPeer))
{
return;
}
Complete(
RendezvousConnectionState.Connected,
ConnectionOutcomeKind.Connected,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.None,
RendezvousConnectionPhase.Complete,
peer);
}
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
if (State == RendezvousConnectionState.Connecting
&& ReferenceEquals(peer, _connectingPeer))
{
ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
? ConnectionOutcomeKind.DirectConnectTimedOut
: disconnectInfo.Reason == DisconnectReason.ConnectionFailed
? ConnectionOutcomeKind.TransportError
: ConnectionOutcomeKind.HostRejected;
Complete(
kind == ConnectionOutcomeKind.DirectConnectTimedOut
? RendezvousConnectionState.TimedOut
: RendezvousConnectionState.Rejected,
kind,
kind == ConnectionOutcomeKind.HostRejected
? RendezvousConnectionOutcomeSource.RemoteHost
: RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
}
}
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
{
_ = socketError;
if (State == RendezvousConnectionState.Punching && endpoint.Equals(_mediator))
{
Complete(
RendezvousConnectionState.Rejected,
ConnectionOutcomeKind.MediatorUnavailable,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.Mediation,
RendezvousConnectionPhase.Mediation);
}
else if (State == RendezvousConnectionState.Connecting
&& endpoint.Equals(_directEndpoint))
{
DisconnectPendingPeer();
Complete(
RendezvousConnectionState.Rejected,
ConnectionOutcomeKind.TransportError,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
}
}
private void DisconnectPendingPeer()
{
if (_connectingPeer is not null && State == RendezvousConnectionState.Connecting)
{
_connectingPeer.Disconnect();
}
}
private void Complete(
RendezvousConnectionState terminalState,
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
NetPeer? peer = null)
{
RendezvousConnectionCompletedEventArgs completion;
lock (_completionGate)
{
if (_outcome is not null)
{
return;
}
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
kind,
source,
category,
phase,
_clock.Elapsed - _startedAt,
ShouldOfferFallback(kind) ? _dedicatedFallback : null,
peer);
State = terminalState;
if (kind == ConnectionOutcomeKind.Connected)
{
ConnectedPeer = peer;
}
Volatile.Write(ref _outcome, outcome);
ReleaseSubscriptions();
completion = new(terminalState, outcome);
}
Completed?.Invoke(this, completion);
}
private void ReleaseSubscriptions()
{
lock (_completionGate)
{
if (_subscriptionsReleased)
{
return;
}
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_networkEvents.RendezvousNetworkError -= OnNetworkError;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
}
}
private void CompleteManagerStopped() => Complete(
RendezvousConnectionState.ManagerStopped,
ConnectionOutcomeKind.ManagerStopped,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
private RendezvousConnectionPhase CurrentPhase() => State switch
{
RendezvousConnectionState.Punching => RendezvousConnectionPhase.NatTraversal,
RendezvousConnectionState.Connecting => RendezvousConnectionPhase.DirectConnection,
_ => RendezvousConnectionPhase.Complete,
};
private static bool ShouldOfferFallback(ConnectionOutcomeKind kind) => kind is not (
ConnectionOutcomeKind.Connected
or ConnectionOutcomeKind.Cancelled
or ConnectionOutcomeKind.Disposed);
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
left <= right ? left : right;
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
}
}
}
@@ -0,0 +1,228 @@
using System.Diagnostics;
using System.Security.Cryptography;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousConnectionState
{
Punching = 1,
Connecting = 2,
Connected = 3,
Cancelled = 4,
TimedOut = 5,
Rejected = 6,
ManagerStopped = 7,
Disposed = 8,
}
public sealed class RendezvousConnectionCompletedEventArgs : EventArgs
{
[Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
public RendezvousConnectionCompletedEventArgs(
RendezvousConnectionState state,
NetPeer? peer)
: this(state, RendezvousCompletionInvariant.FromLegacy(state, peer))
{
}
internal RendezvousConnectionCompletedEventArgs(
RendezvousConnectionState state,
RendezvousConnectionOutcome outcome)
{
RendezvousCompletionInvariant.Validate(state, outcome);
State = state;
Outcome = outcome;
}
public RendezvousConnectionState State { get; }
public RendezvousConnectionOutcome Outcome { get; }
public NetPeer? Peer => Outcome.Peer;
}
internal static class RendezvousCompletionInvariant
{
internal static RendezvousConnectionOutcome FromLegacy(
RendezvousConnectionState state,
NetPeer? peer) => state switch
{
RendezvousConnectionState.Connected when peer is not null => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.Connected,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.None,
RendezvousConnectionPhase.Complete,
TimeSpan.Zero,
peer: peer),
RendezvousConnectionState.Cancelled => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Complete,
TimeSpan.Zero),
RendezvousConnectionState.TimedOut => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.DirectConnectTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection,
TimeSpan.Zero),
RendezvousConnectionState.Rejected => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.HostRejected,
RendezvousConnectionOutcomeSource.RemoteHost,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization,
TimeSpan.Zero),
RendezvousConnectionState.ManagerStopped => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.ManagerStopped,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Complete,
TimeSpan.Zero),
RendezvousConnectionState.Disposed => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.Disposed,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Complete,
TimeSpan.Zero),
RendezvousConnectionState.Connected => throw new ArgumentNullException(
nameof(peer),
"A connected completion requires a peer."),
_ => throw new ArgumentOutOfRangeException(
nameof(state),
state,
"A completion event requires a terminal connection state."),
};
internal static void Validate(
RendezvousConnectionState state,
RendezvousConnectionOutcome outcome)
{
if (outcome is null)
{
throw new ArgumentNullException(nameof(outcome));
}
if ((state == RendezvousConnectionState.Connected) != outcome.IsSuccess)
{
throw new ArgumentException(
"The connection state and typed outcome contradict each other.",
nameof(outcome));
}
}
}
public sealed class RendezvousCoordinatorOptions
{
public int MaximumPunchRequests { get; set; } = 5;
public int MaximumAttemptChecksPerPoll { get; set; } = 128;
public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan PunchTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan DirectConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20);
public double JitterRatio { get; set; } = 0.2;
public NetworkEndpoint? DedicatedFallbackOverride { get; set; }
internal RendezvousCoordinatorOptions CopyAndValidate()
{
if (MaximumPunchRequests is < 1 or > 20
|| MaximumAttemptChecksPerPoll is < 1 or > 1_024
|| InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10)
|| MaximumPunchRetryDelay < InitialPunchRetryDelay
|| MaximumPunchRetryDelay > TimeSpan.FromSeconds(10)
|| PunchTimeout <= TimeSpan.Zero
|| PunchTimeout > TimeSpan.FromSeconds(30)
|| DirectConnectTimeout <= TimeSpan.Zero
|| DirectConnectTimeout > TimeSpan.FromSeconds(30)
|| ConnectionTicketLifetime <= TimeSpan.Zero
|| ConnectionTicketLifetime > TimeSpan.FromSeconds(20)
|| JitterRatio is < 0 or > 1
|| DedicatedFallbackOverride is not null
&& !ContractValidation.IsNetworkEndpointValid(DedicatedFallbackOverride))
{
throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions));
}
return new RendezvousCoordinatorOptions
{
MaximumPunchRequests = MaximumPunchRequests,
MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll,
InitialPunchRetryDelay = InitialPunchRetryDelay,
MaximumPunchRetryDelay = MaximumPunchRetryDelay,
PunchTimeout = PunchTimeout,
DirectConnectTimeout = DirectConnectTimeout,
ConnectionTicketLifetime = ConnectionTicketLifetime,
JitterRatio = JitterRatio,
DedicatedFallbackOverride = RendezvousEndpoint.Copy(DedicatedFallbackOverride),
};
}
}
internal interface IRendezvousCoordinatorClock
{
DateTimeOffset UtcNow { get; }
TimeSpan Elapsed { get; }
}
internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock
{
private readonly long _origin = Stopwatch.GetTimestamp();
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
public TimeSpan Elapsed => TimeSpan.FromSeconds(
(Stopwatch.GetTimestamp() - _origin) / (double)Stopwatch.Frequency);
}
internal static class RendezvousManagerGuard
{
internal static void Validate(
NetManager manager,
RendezvousNetListener networkEvents)
{
networkEvents.ValidateManager(manager);
if (!manager.IsRunning)
{
throw new InvalidOperationException("The caller-owned LiteNetLib manager must be running.");
}
if (!manager.NatPunchEnabled
|| manager.UnsyncedEvents
|| manager.NatPunchModule.UnsyncedEvents)
{
throw new InvalidOperationException(
"The caller-owned manager must enable NAT punching and synchronized event dispatch.");
}
}
}
internal sealed class RendezvousPunchRetrySchedule(
RendezvousCoordinatorOptions options,
IRendezvousCoordinatorClock clock)
{
public int RequestsSent { get; private set; }
public TimeSpan NextRequestAt { get; private set; } = TimeSpan.Zero;
public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests;
public bool IsDue(TimeSpan elapsed) => elapsed >= NextRequestAt;
public void RecordRequest()
{
int exponent = Math.Min(RequestsSent, 30);
RequestsSent++;
double milliseconds = Math.Min(
options.InitialPunchRetryDelay.TotalMilliseconds * Math.Pow(2, exponent),
options.MaximumPunchRetryDelay.TotalMilliseconds);
if (options.JitterRatio > 0)
{
Span<byte> random = stackalloc byte[1];
RandomNumberGenerator.Fill(random);
double unit = random[0] / 255d;
double multiplier = 1 - options.JitterRatio + (2 * options.JitterRatio * unit);
milliseconds = Math.Min(
milliseconds * multiplier,
options.MaximumPunchRetryDelay.TotalMilliseconds);
}
NextRequestAt = clock.Elapsed + TimeSpan.FromMilliseconds(milliseconds);
}
}
@@ -0,0 +1,813 @@
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousHostState
{
Active = 1,
ManagerStopped = 2,
Disposed = 3,
}
public sealed class RendezvousHostAttemptCompletedEventArgs : EventArgs
{
[Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
public RendezvousHostAttemptCompletedEventArgs(
JoinAttemptId attemptId,
RendezvousConnectionState state,
NetPeer? peer)
: this(attemptId, state, RendezvousCompletionInvariant.FromLegacy(state, peer))
{
}
internal RendezvousHostAttemptCompletedEventArgs(
JoinAttemptId attemptId,
RendezvousConnectionState state,
RendezvousConnectionOutcome outcome)
{
if (attemptId.Value == Guid.Empty)
{
throw new ArgumentException("The completed attempt ID is invalid.", nameof(attemptId));
}
RendezvousCompletionInvariant.Validate(state, outcome);
AttemptId = attemptId;
State = state;
Outcome = outcome;
}
public JoinAttemptId AttemptId { get; }
public RendezvousConnectionState State { get; }
public RendezvousConnectionOutcome Outcome { get; }
public NetPeer? Peer => Outcome.Peer;
}
public sealed class RendezvousHostCoordinator : IDisposable
{
private readonly NetManager _manager;
private readonly RendezvousNetListener _networkEvents;
private readonly EventBasedNatPunchListener _punchEvents;
private readonly IPEndPoint _mediator;
private readonly PublishedSession _session;
private readonly IRendezvousJoinClient _joinClient;
private readonly RendezvousCoordinatorOptions _options;
private readonly IRendezvousCoordinatorClock _clock;
private readonly ConnectionTicketValidator _tickets;
private readonly Dictionary<JoinAttemptId, PendingHostAttempt> _attempts = [];
private readonly Dictionary<NetPeer, JoinAttemptId> _acceptedPeers = [];
private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = [];
private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = [];
private readonly Queue<JoinAttemptId> _attemptSchedule = [];
private readonly SortedDictionary<long, Queue<HostAttemptDeadline>> _deadlines = [];
private readonly List<JoinAttemptId> _cleanupScratch = [];
private HostJoinAttempt[]? _latestSnapshot;
private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue;
private DateTimeOffset _nextTerminalCleanupAt = DateTimeOffset.MinValue;
private int _refreshing;
private int _polling;
private bool _subscriptionsReleased;
private int _disposed;
public RendezvousHostCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
PublishedSession session,
IRendezvousJoinClient joinClient,
RendezvousCoordinatorOptions? options = null)
: this(
manager,
networkEvents,
mediator,
session,
joinClient,
options,
new SystemRendezvousCoordinatorClock(),
null)
{
}
internal RendezvousHostCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
PublishedSession session,
IRendezvousJoinClient joinClient,
RendezvousCoordinatorOptions? options,
IRendezvousCoordinatorClock clock,
ConnectionTicketValidator? tickets)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
_punchEvents = _networkEvents.PunchEvents;
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_session = session ?? throw new ArgumentNullException(nameof(session));
_joinClient = joinClient ?? throw new ArgumentNullException(nameof(joinClient));
_options = (options ?? new RendezvousCoordinatorOptions()).CopyAndValidate();
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_tickets = tickets ?? new ConnectionTicketValidator();
RendezvousManagerGuard.Validate(_manager, _networkEvents);
ValidateInputs();
_networkEvents.RendezvousConnectionRequest += OnConnectionRequest;
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
_networkEvents.RendezvousNetworkError += OnNetworkError;
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
}
public event EventHandler<RendezvousHostAttemptCompletedEventArgs>? AttemptCompleted;
public RendezvousHostState State { get; private set; } = RendezvousHostState.Active;
public int PendingAttemptCount => _attempts.Count;
internal int DeferredRequestCount => _deferredRequests.Count;
public async Task<RendezvousClientResult<int>> RefreshJoinAttemptsAsync(
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (Interlocked.Exchange(ref _refreshing, 1) != 0)
{
throw new InvalidOperationException("A host invitation refresh is already running.");
}
try
{
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
await _joinClient.BrowseAllForHostAsync(
_session,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
return RendezvousClientResult.Failure<int>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
HostJoinAttempt[] snapshot = result.Value.Select(CopyAttempt).ToArray();
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
Interlocked.Exchange(ref _latestSnapshot, snapshot);
if (Volatile.Read(ref _disposed) != 0)
{
Interlocked.Exchange(ref _latestSnapshot, null);
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
return RendezvousClientResult.Success(snapshot.Length);
}
finally
{
Volatile.Write(ref _refreshing, 0);
}
}
public void Poll()
{
ThrowIfDisposed();
if (State != RendezvousHostState.Active)
{
return;
}
if (Interlocked.Exchange(ref _polling, 1) != 0)
{
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
}
try
{
ApplySnapshots();
if (!_manager.IsRunning)
{
Stop(
RendezvousHostState.ManagerStopped,
RendezvousConnectionState.ManagerStopped,
ConnectionOutcomeKind.ManagerStopped);
return;
}
_manager.NatPunchModule.PollEvents();
_manager.PollEvents();
_manager.NatPunchModule.PollEvents();
if (State != RendezvousHostState.Active)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
TimeSpan elapsed = _clock.Elapsed;
if (!_manager.IsRunning)
{
Stop(
RendezvousHostState.ManagerStopped,
RendezvousConnectionState.ManagerStopped,
ConnectionOutcomeKind.ManagerStopped);
return;
}
RefreshPresence(now);
ProcessDueDeadlines(elapsed);
if (State != RendezvousHostState.Active)
{
return;
}
int checks = Math.Min(
_attemptSchedule.Count,
_options.MaximumAttemptChecksPerPoll);
for (int index = 0; index < checks; index++)
{
JoinAttemptId attemptId = _attemptSchedule.Dequeue();
if (!_attempts.TryGetValue(attemptId, out PendingHostAttempt? attempt))
{
continue;
}
if (attempt.State != RendezvousConnectionState.Punching)
{
continue;
}
if (attempt.Retry.IsDue(elapsed))
{
if (attempt.Retry.IsExhausted)
{
CompleteAttempt(
attemptId,
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.PunchTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.NatTraversal,
RendezvousConnectionPhase.NatTraversal);
continue;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
attempt.Invitation.MediationHandle,
attempt.Invitation.HostPunchCapability));
attempt.Retry.RecordRequest();
}
_attemptSchedule.Enqueue(attemptId);
}
if (now >= _nextTerminalCleanupAt)
{
_cleanupScratch.Clear();
foreach (KeyValuePair<JoinAttemptId, DateTimeOffset> terminal in _terminalAttempts)
{
if (terminal.Value <= now)
{
_cleanupScratch.Add(terminal.Key);
}
}
foreach (JoinAttemptId attemptId in _cleanupScratch)
{
_terminalAttempts.Remove(attemptId);
}
_nextTerminalCleanupAt = now + TimeSpan.FromSeconds(1);
}
}
finally
{
Volatile.Write(ref _polling, 0);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
Stop(
RendezvousHostState.Disposed,
RendezvousConnectionState.Disposed,
ConnectionOutcomeKind.Disposed);
Interlocked.Exchange(ref _latestSnapshot, null);
_attemptSchedule.Clear();
_deadlines.Clear();
_terminalAttempts.Clear();
_cleanupScratch.Clear();
_tickets.Dispose();
}
public override string ToString() =>
$"[RendezvousHostCoordinator {_session.ListingId}; credentials redacted]";
private void ApplySnapshots()
{
HostJoinAttempt[]? latest = Interlocked.Exchange(ref _latestSnapshot, null);
if (latest is null)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
TimeSpan elapsed = _clock.Elapsed;
foreach (HostJoinAttempt invitation in latest)
{
if (invitation.AttemptId.Value == Guid.Empty
|| invitation.MediationHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(invitation.HostPunchCapability)
|| !ContractValidation.IsConnectionTicketValid(
invitation.ConnectionTicketDigest))
{
continue;
}
if (invitation.IsCancelled)
{
if (_attempts.ContainsKey(invitation.AttemptId))
{
CompleteAttempt(
invitation.AttemptId,
RendezvousConnectionState.Cancelled,
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.RendezvousService,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Authorization);
}
_terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt;
continue;
}
if (invitation.ExpiresAt <= now
|| _attempts.ContainsKey(invitation.AttemptId)
|| _terminalAttempts.ContainsKey(invitation.AttemptId))
{
continue;
}
TimeSpan attemptDeadline = elapsed + (invitation.ExpiresAt - now);
TimeSpan punchDeadline = Min(
attemptDeadline,
elapsed + _options.PunchTimeout);
_attempts.Add(
invitation.AttemptId,
new PendingHostAttempt(
CopyAttempt(invitation),
new RendezvousPunchRetrySchedule(_options, _clock),
elapsed,
attemptDeadline,
punchDeadline));
EnqueueDeadline(
new HostAttemptDeadline(
invitation.AttemptId,
RendezvousConnectionState.Punching,
punchDeadline));
_attemptSchedule.Enqueue(invitation.AttemptId);
}
}
private void RefreshPresence(DateTimeOffset now)
{
if (now < _nextPresenceAt || now >= _session.ExpiresAt)
{
return;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
_session.HostPresenceHandle,
_session.HostPresenceCapability));
_nextPresenceAt = now + TimeSpan.FromSeconds(_session.HostPresenceRefreshAfterSeconds);
}
private void OnNatIntroductionSuccess(
IPEndPoint target,
NatAddressType addressType,
string encodedIntroduction)
{
_ = target;
_ = addressType;
if (!NatIntroductionTokenCodec.TryDecode(
encodedIntroduction,
out NatIntroductionToken? introduction)
|| introduction is null
|| !_attempts.TryGetValue(introduction.AttemptId, out PendingHostAttempt? attempt)
|| !NatIntroductionTokenCodec.MatchesDigest(
introduction.ConnectionTicket,
attempt.Invitation.ConnectionTicketDigest)
|| !_tickets.TryAuthorize(
introduction.AttemptId,
introduction.ConnectionTicket,
Min(
attempt.Invitation.ExpiresAt,
_clock.UtcNow + _options.ConnectionTicketLifetime)))
{
return;
}
attempt.State = RendezvousConnectionState.Connecting;
attempt.DirectDeadline = Min(
attempt.AttemptDeadline,
_clock.Elapsed + _options.DirectConnectTimeout);
EnqueueDeadline(new HostAttemptDeadline(
introduction.AttemptId,
RendezvousConnectionState.Connecting,
attempt.DirectDeadline.Value));
if (_deferredRequests.Remove(
introduction.AttemptId,
out DeferredConnectionRequest? deferred))
{
AcceptAuthorizedRequest(
introduction.AttemptId,
attempt,
deferred.Request,
deferred.ConnectionTicket);
}
}
private void OnConnectionRequest(ConnectionRequest request)
{
ReadOnlySpan<byte> data = request.Data.GetRemainingBytesSpan();
if (!DirectConnectionRequestCodec.IsRendezvousRequest(data))
{
return;
}
if (!DirectConnectionRequestCodec.TryDecode(data, out DirectConnectionRequest? connection)
|| connection is null
|| !_attempts.TryGetValue(connection.AttemptId, out PendingHostAttempt? attempt)
|| !NatIntroductionTokenCodec.MatchesDigest(
connection.ConnectionTicket,
attempt.Invitation.ConnectionTicketDigest))
{
request.RejectForce([]);
return;
}
if (attempt.State == RendezvousConnectionState.Punching)
{
_deferredRequests[connection.AttemptId] = new(
request,
connection.ConnectionTicket);
return;
}
if (attempt.State != RendezvousConnectionState.Connecting)
{
request.RejectForce([]);
return;
}
AcceptAuthorizedRequest(
connection.AttemptId,
attempt,
request,
connection.ConnectionTicket);
}
private void OnPeerConnected(NetPeer peer)
{
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{
CompleteAttempt(
attemptId,
RendezvousConnectionState.Connected,
ConnectionOutcomeKind.Connected,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.None,
RendezvousConnectionPhase.Complete,
peer);
}
}
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
_ = disconnectInfo;
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{
ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
? ConnectionOutcomeKind.DirectConnectTimedOut
: ConnectionOutcomeKind.TransportError;
CompleteAttempt(
attemptId,
kind == ConnectionOutcomeKind.DirectConnectTimedOut
? RendezvousConnectionState.TimedOut
: RendezvousConnectionState.Rejected,
kind,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
}
}
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
{
_ = socketError;
if (!endpoint.Equals(_mediator))
{
return;
}
foreach (JoinAttemptId attemptId in _attempts
.Where(static item => item.Value.State == RendezvousConnectionState.Punching)
.Select(static item => item.Key)
.ToArray())
{
CompleteAttempt(
attemptId,
RendezvousConnectionState.Rejected,
ConnectionOutcomeKind.MediatorUnavailable,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.Mediation,
RendezvousConnectionPhase.Mediation);
}
}
private void CompleteAttempt(
JoinAttemptId attemptId,
RendezvousConnectionState state,
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
NetPeer? peer = null)
{
if (TryCompleteAttempt(
attemptId,
state,
kind,
source,
category,
phase,
peer,
out RendezvousHostAttemptCompletedEventArgs? completion))
{
AttemptCompleted?.Invoke(this, completion!);
}
}
private bool TryCompleteAttempt(
JoinAttemptId attemptId,
RendezvousConnectionState state,
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
NetPeer? peer,
out RendezvousHostAttemptCompletedEventArgs? completion)
{
completion = null;
if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt))
{
return false;
}
if (attempt.AcceptedPeer is not null)
{
_acceptedPeers.Remove(attempt.AcceptedPeer);
if (kind != ConnectionOutcomeKind.Connected)
{
attempt.AcceptedPeer.Disconnect();
}
}
if (_deferredRequests.Remove(attemptId, out DeferredConnectionRequest? deferred))
{
deferred.Request.RejectForce([]);
}
_tickets.Revoke(attemptId);
_terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt;
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
kind,
source,
category,
phase,
_clock.Elapsed - attempt.StartedAt,
peer: peer);
completion = new(attemptId, state, outcome);
return true;
}
private void Stop(
RendezvousHostState hostState,
RendezvousConnectionState attemptState,
ConnectionOutcomeKind outcomeKind)
{
if (State != RendezvousHostState.Active)
{
return;
}
State = hostState;
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray())
{
RendezvousConnectionPhase phase = _attempts[attemptId].State
== RendezvousConnectionState.Connecting
? RendezvousConnectionPhase.DirectConnection
: RendezvousConnectionPhase.NatTraversal;
if (TryCompleteAttempt(
attemptId,
attemptState,
outcomeKind,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
phase,
null,
out RendezvousHostAttemptCompletedEventArgs? completion))
{
completions.Add(completion!);
}
}
ReleaseSubscriptions();
foreach (RendezvousHostAttemptCompletedEventArgs completion in completions)
{
AttemptCompleted?.Invoke(this, completion);
}
}
private void ReleaseSubscriptions()
{
if (_subscriptionsReleased)
{
return;
}
_networkEvents.RendezvousConnectionRequest -= OnConnectionRequest;
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_networkEvents.RendezvousNetworkError -= OnNetworkError;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
}
private void ValidateInputs()
{
if (_mediator.Port is < 1 or > 65_535
|| _session.HostPresenceHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(_session.HostPresenceCapability)
|| _session.HostPresenceRefreshAfterSeconds < 1
|| _session.ExpiresAt <= _clock.UtcNow)
{
throw new ArgumentException("The host traversal inputs are invalid.");
}
}
private static HostJoinAttempt CopyAttempt(HostJoinAttempt attempt) => new()
{
AttemptId = attempt.AttemptId,
MediationHandle = attempt.MediationHandle,
HostPunchCapability = attempt.HostPunchCapability,
ConnectionTicketDigest = attempt.ConnectionTicketDigest,
IsCancelled = attempt.IsCancelled,
ExpiresAt = attempt.ExpiresAt,
};
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
left <= right ? left : right;
private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) =>
left <= right ? left : right;
private void EnqueueDeadline(HostAttemptDeadline deadline)
{
if (!_deadlines.TryGetValue(deadline.Deadline.Ticks, out Queue<HostAttemptDeadline>? bucket))
{
bucket = new Queue<HostAttemptDeadline>();
_deadlines.Add(deadline.Deadline.Ticks, bucket);
}
bucket.Enqueue(deadline);
}
private void ProcessDueDeadlines(TimeSpan elapsed)
{
while (_deadlines.Count > 0)
{
KeyValuePair<long, Queue<HostAttemptDeadline>> first = _deadlines.First();
if (first.Key > elapsed.Ticks)
{
return;
}
HostAttemptDeadline deadline = first.Value.Dequeue();
if (first.Value.Count == 0)
{
_deadlines.Remove(first.Key);
}
if (!_attempts.TryGetValue(deadline.AttemptId, out PendingHostAttempt? attempt)
|| attempt.State != deadline.ExpectedState
|| (deadline.ExpectedState == RendezvousConnectionState.Punching
? attempt.PunchDeadline
: attempt.DirectDeadline) != deadline.Deadline)
{
continue;
}
bool expired = elapsed >= attempt.AttemptDeadline;
CompleteAttempt(
deadline.AttemptId,
RendezvousConnectionState.TimedOut,
expired
? ConnectionOutcomeKind.AttemptExpired
: deadline.ExpectedState == RendezvousConnectionState.Punching
? ConnectionOutcomeKind.PunchTimedOut
: ConnectionOutcomeKind.DirectConnectTimedOut,
expired
? RendezvousConnectionOutcomeSource.RendezvousService
: RendezvousConnectionOutcomeSource.LocalTraversal,
expired
? RendezvousConnectionFailureCategory.Authorization
: deadline.ExpectedState == RendezvousConnectionState.Punching
? RendezvousConnectionFailureCategory.NatTraversal
: RendezvousConnectionFailureCategory.DirectConnection,
expired
? RendezvousConnectionPhase.Authorization
: deadline.ExpectedState == RendezvousConnectionState.Punching
? RendezvousConnectionPhase.NatTraversal
: RendezvousConnectionPhase.DirectConnection);
if (State != RendezvousHostState.Active)
{
return;
}
}
}
private void AcceptAuthorizedRequest(
JoinAttemptId attemptId,
PendingHostAttempt attempt,
ConnectionRequest request,
string connectionTicket)
{
ConnectionTicketConsumptionResult consumption = _tickets.Consume(
attemptId,
connectionTicket);
if (consumption != ConnectionTicketConsumptionResult.Accepted)
{
request.RejectForce([]);
return;
}
NetPeer peer = request.Accept();
attempt.AcceptedPeer = peer;
_acceptedPeers[peer] = attemptId;
}
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
}
private sealed class PendingHostAttempt(
HostJoinAttempt invitation,
RendezvousPunchRetrySchedule retry,
TimeSpan startedAt,
TimeSpan attemptDeadline,
TimeSpan punchDeadline)
{
internal HostJoinAttempt Invitation { get; } = invitation;
internal RendezvousPunchRetrySchedule Retry { get; } = retry;
internal TimeSpan StartedAt { get; } = startedAt;
internal TimeSpan AttemptDeadline { get; } = attemptDeadline;
internal TimeSpan PunchDeadline { get; } = punchDeadline;
internal TimeSpan? DirectDeadline { get; set; }
internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching;
internal NetPeer? AcceptedPeer { get; set; }
}
private sealed class HostAttemptDeadline(
JoinAttemptId attemptId,
RendezvousConnectionState expectedState,
TimeSpan deadline)
{
internal JoinAttemptId AttemptId { get; } = attemptId;
internal RendezvousConnectionState ExpectedState { get; } = expectedState;
internal TimeSpan Deadline { get; } = deadline;
}
private sealed class DeferredConnectionRequest(
ConnectionRequest request,
string connectionTicket)
{
internal ConnectionRequest Request { get; } = request;
internal string ConnectionTicket { get; } = connectionTicket;
}
}
@@ -0,0 +1,114 @@
using System.Net;
using System.Net.Sockets;
using LiteNetLib;
using LiteNetLib.Utils;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousNetListener : INetEventListener
{
private NetManager? _manager;
public EventBasedNetListener GameplayEvents { get; } = new();
public EventBasedNatPunchListener PunchEvents { get; } = new();
public NetManager CreateManager()
{
if (_manager is not null)
{
throw new InvalidOperationException(
"This Rendezvous listener is already bound to a LiteNetLib manager.");
}
NetManager manager = new(this) { NatPunchEnabled = true };
manager.NatPunchModule.Init(PunchEvents);
_manager = manager;
return manager;
}
internal event Action<NetPeer>? RendezvousPeerConnected;
internal event Action<NetPeer, DisconnectInfo>? RendezvousPeerDisconnected;
internal event Action<ConnectionRequest>? RendezvousConnectionRequest;
internal event Action<IPEndPoint, SocketError>? RendezvousNetworkError;
internal void ValidateManager(NetManager manager)
{
if (!ReferenceEquals(_manager, manager))
{
throw new InvalidOperationException(
"The LiteNetLib manager must be created by this Rendezvous listener.");
}
}
public void OnPeerConnected(NetPeer peer)
{
RendezvousPeerConnected?.Invoke(peer);
((INetEventListener)GameplayEvents).OnPeerConnected(peer);
}
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
RendezvousPeerDisconnected?.Invoke(peer, disconnectInfo);
((INetEventListener)GameplayEvents).OnPeerDisconnected(peer, disconnectInfo);
}
public void OnNetworkError(IPEndPoint endPoint, SocketError socketError)
{
RendezvousNetworkError?.Invoke(endPoint, socketError);
((INetEventListener)GameplayEvents).OnNetworkError(endPoint, socketError);
}
public void OnNetworkReceive(
NetPeer peer,
NetPacketReader reader,
byte channelNumber,
DeliveryMethod deliveryMethod) =>
((INetEventListener)GameplayEvents).OnNetworkReceive(
peer,
reader,
channelNumber,
deliveryMethod);
public void OnNetworkReceiveUnconnected(
IPEndPoint remoteEndPoint,
NetPacketReader reader,
UnconnectedMessageType messageType) =>
((INetEventListener)GameplayEvents).OnNetworkReceiveUnconnected(
remoteEndPoint,
reader,
messageType);
public void OnNetworkLatencyUpdate(NetPeer peer, int latency) =>
((INetEventListener)GameplayEvents).OnNetworkLatencyUpdate(peer, latency);
public void OnConnectionRequest(ConnectionRequest request)
{
int position = request.Data.Position;
bool isRendezvous = DirectConnectionRequestCodec.IsRendezvousRequest(
request.Data.GetRemainingBytesSpan());
request.Data.SetPosition(position);
if (!isRendezvous)
{
((INetEventListener)GameplayEvents).OnConnectionRequest(request);
return;
}
Action<ConnectionRequest>? handler = RendezvousConnectionRequest;
if (handler is null)
{
request.RejectForce([]);
return;
}
handler(request);
}
public void OnMessageDelivered(NetPeer peer, object userData) =>
((INetEventListener)GameplayEvents).OnMessageDelivered(peer, userData);
public void OnNtpResponse(NtpPacket packet) =>
((INetEventListener)GameplayEvents).OnNtpResponse(packet);
public void OnPeerAddressChanged(NetPeer peer, IPEndPoint previousAddress) =>
((INetEventListener)GameplayEvents).OnPeerAddressChanged(peer, previousAddress);
}
@@ -49,6 +49,27 @@ public enum ConnectionOutcomeKind
HostRejected = 7, HostRejected = 7,
TransportFailed = 8, TransportFailed = 8,
FallbackOffered = 9, FallbackOffered = 9,
DirectoryNotFound = 10,
AttemptExpired = 11,
Unauthorized = 12,
RateLimited = 13,
NoHostPresence = 14,
ServiceUnavailable = 15,
MediatorUnavailable = 16,
PunchTimedOut = 17,
DirectConnectTimedOut = 18,
TransportError = 19,
ManagerStopped = 20,
Disposed = 21,
}
public enum ConnectionElapsedBucket
{
UnderOneSecond = 1,
OneToFiveSeconds = 2,
FiveToFifteenSeconds = 3,
FifteenToThirtySeconds = 4,
ThirtySecondsOrMore = 5,
} }
public enum UdpPresenceMessageType : byte public enum UdpPresenceMessageType : byte
@@ -23,6 +23,8 @@ public static class ContractLimits
public const int OpaqueHttpCredentialMaxCharacters = 1_024; public const int OpaqueHttpCredentialMaxCharacters = 1_024;
public const int UdpCapabilityMaxCharacters = 192; public const int UdpCapabilityMaxCharacters = 192;
public const int ConnectionTicketMaxCharacters = 192; public const int ConnectionTicketMaxCharacters = 192;
public const int DerivedCredentialCharacters = 43;
public const int NatPunchRequestTokenCharacters = 192;
public const int LiteNetLibNatTokenMaxCharacters = 256; public const int LiteNetLibNatTokenMaxCharacters = 256;
public const int SessionCapacityMaxPlayers = 10_000; public const int SessionCapacityMaxPlayers = 10_000;
} }
@@ -45,6 +45,23 @@ public static class ContractValidation
public static bool IsDiagnosticCodeValid(string? value) => public static bool IsDiagnosticCodeValid(string? value) =>
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters); value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
public static bool IsReportableConnectionOutcome(ConnectionOutcomeKind outcome) => outcome is
ConnectionOutcomeKind.Connected
or ConnectionOutcomeKind.Cancelled
or ConnectionOutcomeKind.TimedOut
or ConnectionOutcomeKind.StaleHost
or ConnectionOutcomeKind.TransportFailed
or ConnectionOutcomeKind.FallbackOffered
or ConnectionOutcomeKind.AttemptExpired
or ConnectionOutcomeKind.NoHostPresence
or ConnectionOutcomeKind.MediatorUnavailable
or ConnectionOutcomeKind.PunchTimedOut
or ConnectionOutcomeKind.DirectConnectTimedOut
or ConnectionOutcomeKind.HostRejected
or ConnectionOutcomeKind.TransportError
or ConnectionOutcomeKind.ManagerStopped
or ConnectionOutcomeKind.Disposed;
public static bool IsBuildVersionValid(string? value) => public static bool IsBuildVersionValid(string? value) =>
!string.IsNullOrWhiteSpace(value) !string.IsNullOrWhiteSpace(value)
&& IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes); && IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class ReportConnectionOutcomeRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public ConnectionOutcomeKind Outcome { get; set; }
public ConnectionElapsedBucket ElapsedBucket { get; set; }
[Obsolete("Use ElapsedBucket. Exact elapsed time is accepted only for v1 compatibility and is not retained.")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int ElapsedMilliseconds { get; set; }
[Obsolete("Diagnostic codes are accepted only for v1 compatibility and are not retained.")]
public string? DiagnosticCode { get; set; }
}
public sealed class ReportConnectionOutcomeResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public bool Accepted { get; set; }
[JsonRequired]
public bool IsDuplicate { get; set; }
}
@@ -37,6 +37,9 @@ public sealed class CreateJoinAttemptResponse
[JsonRequired] [JsonRequired]
public string ClientPunchCapability { get; set; } = string.Empty; public string ClientPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public string ConnectionTicketDigest { get; set; } = string.Empty;
[JsonRequired] [JsonRequired]
public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset ExpiresAt { get; set; }
public NetworkEndpoint? DedicatedFallback { get; set; } public NetworkEndpoint? DedicatedFallback { get; set; }
@@ -53,6 +56,12 @@ public sealed class HostJoinAttempt
[JsonRequired] [JsonRequired]
public string HostPunchCapability { get; set; } = string.Empty; public string HostPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public string ConnectionTicketDigest { get; set; } = string.Empty;
[JsonRequired]
public bool IsCancelled { get; set; }
[JsonRequired] [JsonRequired]
public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset ExpiresAt { get; set; }
} }
@@ -67,26 +76,3 @@ public sealed class BrowseHostJoinAttemptsResponse
public string? NextCursor { get; set; } public string? NextCursor { get; set; }
} }
public sealed class ReportConnectionOutcomeRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public ConnectionOutcomeKind Outcome { get; set; }
[JsonRequired]
public int ElapsedMilliseconds { get; set; }
public string? DiagnosticCode { get; set; }
}
public sealed class ReportConnectionOutcomeResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public bool Accepted { get; set; }
}
@@ -39,6 +39,8 @@ public sealed class SessionListing
[JsonRequired] [JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal); public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public NetworkEndpoint? DedicatedFallback { get; set; }
} }
public sealed class RegisterSessionRequest public sealed class RegisterSessionRequest
@@ -75,6 +77,8 @@ public sealed class RegisterSessionRequest
[JsonRequired] [JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal); public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public NetworkEndpoint? DedicatedFallback { get; set; }
} }
public sealed class RegisterSessionResponse public sealed class RegisterSessionResponse
@@ -147,6 +151,8 @@ public sealed class UpdateSessionRequest
[JsonRequired] [JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal); public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public NetworkEndpoint? DedicatedFallback { get; set; }
} }
public sealed class DeleteSessionRequest public sealed class DeleteSessionRequest
@@ -25,7 +25,9 @@ public static class ContractJson
options.AllowTrailingCommas = false; options.AllowTrailingCommas = false;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.MaxDepth = 8; // Nine is the minimum that lets ASP.NET generate the nullable fallback
// OpenAPI schema; the 16 KiB HTTP body limit still bounds parser work.
options.MaxDepth = 9;
options.NumberHandling = JsonNumberHandling.Strict; options.NumberHandling = JsonNumberHandling.Strict;
options.PropertyNameCaseInsensitive = false; options.PropertyNameCaseInsensitive = false;
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
@@ -0,0 +1,149 @@
using System.Security.Cryptography;
using System.Text;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class NatIntroductionToken
{
public JoinAttemptId AttemptId { get; set; }
public string ConnectionTicket { get; set; } = string.Empty;
public override string ToString() =>
$"[NatIntroductionToken {AttemptId}; ticket redacted]";
}
public static class NatIntroductionTokenCodec
{
public const int EncodedLength = ContractLimits.DerivedCredentialCharacters;
private const int DecodedLength = 32;
private const int AttemptIdLength = 16;
private const int AuthenticatorLength = DecodedLength - AttemptIdLength;
public static string Encode(JoinAttemptId attemptId, string derivedAuthenticator)
{
if (attemptId.Value == Guid.Empty
|| !ContractValidation.IsConnectionTicketValid(derivedAuthenticator)
|| !TryDecodeBase64Url(derivedAuthenticator, out byte[]? authenticator)
|| authenticator.Length != DecodedLength)
{
throw new ArgumentException("The NAT introduction token fields are invalid.");
}
byte[] payload = new byte[DecodedLength];
try
{
if (!attemptId.Value.TryWriteBytes(payload.AsSpan(0, AttemptIdLength)))
{
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
}
authenticator.AsSpan(0, AuthenticatorLength).CopyTo(payload.AsSpan(AttemptIdLength));
return EncodeBase64Url(payload);
}
finally
{
CryptographicOperations.ZeroMemory(authenticator);
CryptographicOperations.ZeroMemory(payload);
}
}
public static bool TryDecode(string? encoded, out NatIntroductionToken? token)
{
token = null;
if (!ContractValidation.IsConnectionTicketValid(encoded)
|| !TryDecodeBase64Url(encoded!, out byte[]? payload)
|| payload.Length != DecodedLength)
{
return false;
}
try
{
Guid attemptId = new(payload.AsSpan(0, AttemptIdLength));
if (attemptId == Guid.Empty)
{
return false;
}
token = new NatIntroductionToken
{
AttemptId = new JoinAttemptId(attemptId),
ConnectionTicket = encoded!,
};
return true;
}
finally
{
CryptographicOperations.ZeroMemory(payload);
}
}
public static string ComputeDigest(string connectionTicket)
{
if (!ContractValidation.IsConnectionTicketValid(connectionTicket))
{
throw new ArgumentException("The connection ticket is invalid.", nameof(connectionTicket));
}
byte[] encoded = Encoding.ASCII.GetBytes(connectionTicket);
byte[] digest;
using (SHA256 sha256 = SHA256.Create())
{
digest = sha256.ComputeHash(encoded);
}
CryptographicOperations.ZeroMemory(encoded);
try
{
return EncodeBase64Url(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public static bool MatchesDigest(string? connectionTicket, string? expectedDigest)
{
if (!ContractValidation.IsConnectionTicketValid(connectionTicket)
|| !ContractValidation.IsConnectionTicketValid(expectedDigest))
{
return false;
}
byte[] actual = Encoding.ASCII.GetBytes(ComputeDigest(connectionTicket!));
byte[] expected = Encoding.ASCII.GetBytes(expectedDigest!);
try
{
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
finally
{
CryptographicOperations.ZeroMemory(actual);
CryptographicOperations.ZeroMemory(expected);
}
}
private static bool TryDecodeBase64Url(string? encoded, out byte[] bytes)
{
bytes = [];
if (encoded is null || encoded.Length != EncodedLength)
{
return false;
}
try
{
bytes = Convert.FromBase64String(
encoded.Replace('-', '+').Replace('_', '/') + "=");
return true;
}
catch (FormatException)
{
return false;
}
}
private static string EncodeBase64Url(byte[] value) =>
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,131 @@
namespace FinalFactory.Rendezvous.Contracts;
public enum NatPunchPeerRole
{
HostPresence = 1,
Host = 2,
Client = 3,
}
public sealed class NatPunchRequestToken
{
public NatPunchPeerRole Role { get; set; }
public MediationHandle MediationHandle { get; set; }
public string Capability { get; set; } = string.Empty;
public override string ToString() => "[NatPunchRequestToken: capability redacted]";
}
public static class NatPunchRequestTokenCodec
{
public const int EncodedLength = ContractLimits.NatPunchRequestTokenCharacters;
private const string VersionPrefix = "rv1:";
private const int HandleLength = 32;
private const int CapabilityLength = ContractLimits.DerivedCredentialCharacters;
private const char Separator = ':';
private const char Padding = '.';
public static string Encode(
NatPunchPeerRole role,
MediationHandle mediationHandle,
string capability)
{
if (!TryGetRoleCode(role, out char roleCode)
|| mediationHandle.Value == Guid.Empty
|| capability is null
|| capability.Length != CapabilityLength
|| !ContractValidation.IsCapabilityValid(capability))
{
throw new ArgumentException("The NAT punch request token fields are invalid.");
}
string payload = string.Concat(
VersionPrefix,
roleCode,
Separator,
mediationHandle.Value.ToString("N"),
Separator,
capability);
return payload.PadRight(EncodedLength, Padding);
}
public static bool TryDecode(string? encoded, out NatPunchRequestToken? token)
{
token = null;
if (encoded is null
|| encoded.Length != EncodedLength
|| !encoded.StartsWith(VersionPrefix, StringComparison.Ordinal)
|| !TryParseRole(encoded[VersionPrefix.Length], out NatPunchPeerRole role))
{
return false;
}
int roleSeparator = VersionPrefix.Length + 1;
int handleOffset = roleSeparator + 1;
int capabilitySeparator = handleOffset + HandleLength;
int capabilityOffset = capabilitySeparator + 1;
int paddingOffset = capabilityOffset + CapabilityLength;
string handleText = encoded.Substring(handleOffset, HandleLength);
if (encoded[roleSeparator] != Separator
|| encoded[capabilitySeparator] != Separator
|| !Guid.TryParseExact(handleText, "N", out Guid handle)
|| handle == Guid.Empty
|| !string.Equals(handleText, handle.ToString("N"), StringComparison.Ordinal)
|| !ContainsOnlyPadding(encoded, paddingOffset))
{
return false;
}
string capability = encoded.Substring(capabilityOffset, CapabilityLength);
if (!ContractValidation.IsCapabilityValid(capability))
{
return false;
}
token = new NatPunchRequestToken
{
Role = role,
MediationHandle = new MediationHandle(handle),
Capability = capability,
};
return true;
}
private static bool TryGetRoleCode(NatPunchPeerRole role, out char code)
{
code = role switch
{
NatPunchPeerRole.HostPresence => 'p',
NatPunchPeerRole.Host => 'h',
NatPunchPeerRole.Client => 'c',
_ => default,
};
return code != default;
}
private static bool TryParseRole(char code, out NatPunchPeerRole role)
{
role = code switch
{
'p' => NatPunchPeerRole.HostPresence,
'h' => NatPunchPeerRole.Host,
'c' => NatPunchPeerRole.Client,
_ => default,
};
return role != default;
}
private static bool ContainsOnlyPadding(string value, int offset)
{
for (int index = offset; index < value.Length; index++)
{
if (value[index] != Padding)
{
return false;
}
}
return true;
}
}
@@ -0,0 +1,111 @@
using System.ComponentModel.DataAnnotations;
namespace FinalFactory.Rendezvous.Server.Abuse;
internal sealed class AbuseProtectionOptions
{
public const string SectionName = "Rendezvous:AbuseProtection";
[Range(1, 60)]
public int WindowSeconds { get; set; } = 1;
[Range(1_000, 1_000_000)]
public int MaxTrackedKeys { get; set; } = 100_000;
[Range(0, 100_000)]
public int CriticalTrackedKeyReserve { get; set; } = 2_048;
[Range(1_000, 999_999)]
public int UdpTrackedKeyLimit { get; set; } = 70_000;
public string[] TrustedProxyAddresses { get; set; } = [];
public string[] OperatorAllowedAddresses { get; set; } = [];
[Range(1, 100_000)]
public int HealthGlobalRequestsPerWindow { get; set; } = 1_000;
[Range(1, 10_000)]
public int HealthGlobalConcurrency { get; set; } = 32;
[Range(1, 100_000)]
public int HealthIpPrefixRequestsPerWindow { get; set; } = 120;
[Range(1, 1_000)]
public int HealthIpPrefixConcurrency { get; set; } = 8;
[Range(1, 100_000)]
public int OperatorGlobalRequestsPerWindow { get; set; } = 1_000;
[Range(1, 10_000)]
public int OperatorGlobalConcurrency { get; set; } = 32;
[Range(1, 100_000)]
public int OperatorIpPrefixRequestsPerWindow { get; set; } = 120;
[Range(1, 1_000)]
public int OperatorIpPrefixConcurrency { get; set; } = 8;
[Range(1, 1_000_000)]
public int HttpGlobalRequestsPerWindow { get; set; } = 20_000;
[Range(1, 1_000_000)]
public int HttpOptionalRequestsPerWindow { get; set; } = 18_000;
[Range(1, 100_000)]
public int HttpIpPrefixRequestsPerWindow { get; set; } = 500;
[Range(1, 100_000)]
public int HttpOptionalIpPrefixRequestsPerWindow { get; set; } = 450;
[Range(1, 1_000_000)]
public int HttpOperationRequestsPerWindow { get; set; } = 5_000;
[Range(1, 1_000_000)]
public int HttpTenantRequestsPerWindow { get; set; } = 2_000;
[Range(1, 100_000)]
public int HttpPrincipalRequestsPerWindow { get; set; } = 500;
[Range(1, 100_000)]
public int HttpResourceRequestsPerWindow { get; set; } = 200;
[Range(1, 100_000)]
public int HttpGlobalConcurrency { get; set; } = 1_024;
[Range(1, 100_000)]
public int HttpOptionalConcurrency { get; set; } = 768;
[Range(1, 10_000)]
public int HttpIpPrefixConcurrency { get; set; } = 64;
[Range(1, 10_000)]
public int HttpOptionalIpPrefixConcurrency { get; set; } = 48;
[Range(1, 100_000)]
public int HttpOperationConcurrency { get; set; } = 256;
[Range(1, 100_000)]
public int HttpTenantConcurrency { get; set; } = 256;
[Range(1, 10_000)]
public int HttpPrincipalConcurrency { get; set; } = 32;
[Range(1, 10_000)]
public int HttpResourceConcurrency { get; set; } = 16;
[Range(1, 10_000_000)]
public int UdpGlobalDatagramsPerWindow { get; set; } = 100_000;
[Range(1, 1_000_000)]
public int UdpIpPrefixDatagramsPerWindow { get; set; } = 2_000;
[Range(1, 10_000_000)]
public int UdpOperationDatagramsPerWindow { get; set; } = 50_000;
[Range(1, 100_000)]
public int UdpCapabilityDatagramsPerWindow { get; set; } = 120;
[Range(1, 100_000)]
public int UdpResourceDatagramsPerWindow { get; set; } = 240;
}
@@ -0,0 +1,530 @@
using System.Buffers;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Server.Observability;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Abuse;
internal sealed class AbuseProtectionService
{
private readonly AbuseProtectionOptions _options;
private readonly TimeProvider _timeProvider;
private readonly TrackerState _httpTracker;
private readonly TrackerState _udpTracker;
private readonly RendezvousTelemetry? _telemetry;
private readonly HashSet<string> _operatorAllowedAddresses;
public AbuseProtectionService(
IOptions<AbuseProtectionOptions> options,
TimeProvider? timeProvider = null,
RendezvousTelemetry? telemetry = null)
{
_options = options.Value;
_timeProvider = timeProvider ?? TimeProvider.System;
_telemetry = telemetry;
_operatorAllowedAddresses = options.Value.OperatorAllowedAddresses
.Select(static value => IPAddress.TryParse(value, out IPAddress? address)
? NormalizeAddress(address).ToString()
: string.Empty)
.Where(static value => value.Length > 0)
.ToHashSet(StringComparer.Ordinal);
DateTimeOffset now = _timeProvider.GetUtcNow();
_httpTracker = new(now);
_udpTracker = new(now);
}
public bool TryAcquireHttpIngress(
IPAddress? remoteAddress,
string operation,
out AbuseLease? lease,
out int retryAfterSeconds)
{
string prefix = GetNetworkPrefix(remoteAddress);
List<RateDimension> rates =
[
new("http:rate:global", _options.HttpGlobalRequestsPerWindow),
new($"http:rate:ip:{prefix}", _options.HttpIpPrefixRequestsPerWindow),
new($"http:rate:operation:{operation}", _options.HttpOperationRequestsPerWindow),
];
List<RateDimension> concurrency =
[
new("http:concurrency:global", _options.HttpGlobalConcurrency),
new($"http:concurrency:ip:{prefix}", _options.HttpIpPrefixConcurrency),
new($"http:concurrency:operation:{operation}", _options.HttpOperationConcurrency),
];
if (!IsLeaseCriticalOperation(operation))
{
rates.Add(new("http:rate:optional", _options.HttpOptionalRequestsPerWindow));
rates.Add(new($"http:rate:optional-ip:{prefix}",
_options.HttpOptionalIpPrefixRequestsPerWindow));
concurrency.Add(new("http:concurrency:optional", _options.HttpOptionalConcurrency));
concurrency.Add(new($"http:concurrency:optional-ip:{prefix}",
_options.HttpOptionalIpPrefixConcurrency));
}
return TryAcquire(
[.. rates],
[.. concurrency],
TrackerDomain.Http,
IsLeaseCriticalOperation(operation),
out lease,
out retryAfterSeconds);
}
public bool TryAcquireHealthIngress(
IPAddress? remoteAddress,
out AbuseLease? lease,
out int retryAfterSeconds)
{
string prefix = GetNetworkPrefix(remoteAddress);
RateDimension[] rates =
[
new("health:rate:global", _options.HealthGlobalRequestsPerWindow),
new($"health:rate:ip:{prefix}", _options.HealthIpPrefixRequestsPerWindow),
];
RateDimension[] concurrency =
[
new("health:concurrency:global", _options.HealthGlobalConcurrency),
new($"health:concurrency:ip:{prefix}", _options.HealthIpPrefixConcurrency),
];
return TryAcquire(
rates,
concurrency,
TrackerDomain.Http,
true,
out lease,
out retryAfterSeconds);
}
public bool IsOperatorSourceAllowed(IPAddress? remoteAddress) =>
remoteAddress is not null
&& _operatorAllowedAddresses.Contains(NormalizeAddress(remoteAddress).ToString());
public bool TryAcquireOperatorIngress(
IPAddress? remoteAddress,
out AbuseLease? lease,
out int retryAfterSeconds)
{
string prefix = GetNetworkPrefix(remoteAddress);
RateDimension[] rates =
[
new("operator:rate:global", _options.OperatorGlobalRequestsPerWindow),
new($"operator:rate:ip:{prefix}", _options.OperatorIpPrefixRequestsPerWindow),
];
RateDimension[] concurrency =
[
new("operator:concurrency:global", _options.OperatorGlobalConcurrency),
new($"operator:concurrency:ip:{prefix}", _options.OperatorIpPrefixConcurrency),
];
return TryAcquire(
rates,
concurrency,
TrackerDomain.Http,
true,
out lease,
out retryAfterSeconds);
}
public bool TryAcquireHttpIdentity(
string operation,
string? tenant,
string? principal,
string? resource,
out AbuseLease? lease,
out int retryAfterSeconds) => TryAcquireHttpIdentity(
operation,
null,
tenant,
principal,
resource,
out lease,
out retryAfterSeconds);
public bool TryAcquireHttpIdentity(
string operation,
IPAddress? remoteAddress,
string? tenant,
string? principal,
string? resource,
out AbuseLease? lease,
out int retryAfterSeconds)
{
string sourcePrefix = GetNetworkPrefix(remoteAddress);
List<RateDimension> rates = [];
List<RateDimension> concurrency = [];
AddDimension(rates, concurrency, "tenant", tenant,
_options.HttpTenantRequestsPerWindow, _options.HttpTenantConcurrency);
AddDimension(rates, concurrency, "principal", principal,
_options.HttpPrincipalRequestsPerWindow, _options.HttpPrincipalConcurrency);
AddDimension(rates, concurrency, "resource", resource,
_options.HttpResourceRequestsPerWindow, _options.HttpResourceConcurrency);
return TryAcquire(
[.. rates],
[.. concurrency],
TrackerDomain.Http,
IsLeaseCriticalOperation(operation),
out lease,
out retryAfterSeconds);
void AddDimension(
List<RateDimension> rateDimensions,
List<RateDimension> concurrencyDimensions,
string kind,
string? value,
int rateLimit,
int concurrencyLimit)
{
if (string.IsNullOrEmpty(value))
{
return;
}
if (kind == "resource")
{
string validationKey =
$"http:source-resource:{operation}:{sourcePrefix}:{value}";
rateDimensions.Add(new($"{validationKey}:rate", rateLimit));
concurrencyDimensions.Add(new($"{validationKey}:concurrency", concurrencyLimit));
}
string key = kind == "resource"
? $"http:resource-scoped:{operation}:{tenant ?? string.Empty}|{principal ?? string.Empty}:{value}"
: $"http:{kind}:{operation}:{value}";
rateDimensions.Add(new($"{key}:rate", rateLimit));
concurrencyDimensions.Add(new($"{key}:concurrency", concurrencyLimit));
}
}
public bool TryAcceptUdpIngress(IPAddress? remoteAddress, string operation)
{
string prefix = GetNetworkPrefix(remoteAddress);
RateDimension[] rates =
[
new("udp:rate:global", _options.UdpGlobalDatagramsPerWindow),
new($"udp:rate:ip:{prefix}", _options.UdpIpPrefixDatagramsPerWindow),
new($"udp:rate:operation:{operation}", _options.UdpOperationDatagramsPerWindow),
];
return TryAcquire(
rates,
[],
TrackerDomain.Udp,
false,
out AbuseLease? lease,
out _)
&& DisposeAccepted(lease);
}
public bool TryAcceptUdpIdentity(
string operation,
string capability,
string resource) => TryAcceptUdpIdentity(
operation,
null,
capability,
resource);
public bool TryAcceptUdpIdentity(
string operation,
IPAddress? remoteAddress,
string capability,
string resource)
{
string sourcePrefix = GetNetworkPrefix(remoteAddress);
string capabilityFingerprint = FingerprintSecret(capability);
RateDimension[] rates =
[
new($"udp:rate:capability:{operation}:{capabilityFingerprint}",
_options.UdpCapabilityDatagramsPerWindow),
new($"udp:rate:source-resource:{operation}:{sourcePrefix}:{resource}",
_options.UdpResourceDatagramsPerWindow),
new($"udp:rate:resource:{operation}:{capabilityFingerprint}:{resource}",
_options.UdpResourceDatagramsPerWindow),
];
return TryAcquire(
rates,
[],
TrackerDomain.Udp,
false,
out AbuseLease? lease,
out _)
&& DisposeAccepted(lease);
}
public static string FingerprintSecret(string secret)
{
int byteCount = Encoding.UTF8.GetByteCount(secret);
byte[]? rented = null;
Span<byte> encoded = byteCount <= 1_024
? stackalloc byte[byteCount]
: (rented = ArrayPool<byte>.Shared.Rent(byteCount)).AsSpan(0, byteCount);
Span<byte> digest = stackalloc byte[32];
try
{
_ = Encoding.UTF8.GetBytes(secret, encoded);
_ = SHA256.HashData(encoded, digest);
return Convert.ToHexString(digest[..12]);
}
finally
{
CryptographicOperations.ZeroMemory(encoded);
CryptographicOperations.ZeroMemory(digest);
if (rented is not null)
{
ArrayPool<byte>.Shared.Return(rented);
}
}
}
internal int TrackedKeyCount
{
get
{
int http;
int udp;
lock (_httpTracker.Gate)
{
http = _httpTracker.WindowCounts.Count + _httpTracker.ConcurrencyCounts.Count;
}
lock (_udpTracker.Gate)
{
udp = _udpTracker.WindowCounts.Count + _udpTracker.ConcurrencyCounts.Count;
}
return http + udp;
}
}
private bool TryAcquire(
ReadOnlySpan<RateDimension> rates,
ReadOnlySpan<RateDimension> concurrency,
TrackerDomain domain,
bool canUseCriticalReserve,
out AbuseLease? lease,
out int retryAfterSeconds)
{
TrackerState tracker = domain == TrackerDomain.Udp ? _udpTracker : _httpTracker;
bool accepted;
lock (tracker.Gate)
{
accepted = TryAcquireLocked(
tracker,
rates,
concurrency,
domain,
canUseCriticalReserve,
out lease,
out retryAfterSeconds);
}
if (!accepted)
{
_telemetry?.RecordLimiterDrop(
domain == TrackerDomain.Udp ? "udp" : "http",
"rate-or-concurrency");
}
return accepted;
}
private bool TryAcquireLocked(
TrackerState tracker,
ReadOnlySpan<RateDimension> rates,
ReadOnlySpan<RateDimension> concurrency,
TrackerDomain domain,
bool canUseCriticalReserve,
out AbuseLease? lease,
out int retryAfterSeconds)
{
DateTimeOffset now = _timeProvider.GetUtcNow();
TimeSpan window = TimeSpan.FromSeconds(_options.WindowSeconds);
if (now - tracker.WindowStartedAt >= window || now < tracker.WindowStartedAt)
{
tracker.WindowCounts.Clear();
tracker.WindowStartedAt = now;
}
retryAfterSeconds = Math.Max(
1,
(int)Math.Ceiling((window - (now - tracker.WindowStartedAt)).TotalSeconds));
int stagedNewKeys = 0;
int partitionLimit = domain == TrackerDomain.Udp
? _options.UdpTrackedKeyLimit
: _options.MaxTrackedKeys - _options.UdpTrackedKeyLimit;
int maxTrackedKeys = domain == TrackerDomain.Udp || canUseCriticalReserve
? partitionLimit
: partitionLimit - _options.CriticalTrackedKeyReserve;
if (!CanAcquireAll(
tracker,
tracker.WindowCounts,
rates,
maxTrackedKeys,
ref stagedNewKeys)
|| !CanAcquireAll(
tracker,
tracker.ConcurrencyCounts,
concurrency,
maxTrackedKeys,
ref stagedNewKeys))
{
lease = null;
return false;
}
foreach (RateDimension dimension in rates)
{
tracker.WindowCounts[dimension.Key] =
tracker.WindowCounts.GetValueOrDefault(dimension.Key) + 1;
}
if (concurrency.IsEmpty)
{
lease = null;
return true;
}
string[] acquiredConcurrency = new string[concurrency.Length];
for (int index = 0; index < concurrency.Length; index++)
{
RateDimension dimension = concurrency[index];
tracker.ConcurrencyCounts[dimension.Key] =
tracker.ConcurrencyCounts.GetValueOrDefault(dimension.Key) + 1;
acquiredConcurrency[index] = dimension.Key;
}
lease = new AbuseLease(this, tracker, acquiredConcurrency);
return true;
}
private static bool CanAcquireAll(
TrackerState tracker,
Dictionary<string, int> counts,
ReadOnlySpan<RateDimension> dimensions,
int maxTrackedKeys,
ref int stagedNewKeys)
{
foreach (RateDimension dimension in dimensions)
{
if (counts.TryGetValue(dimension.Key, out int current))
{
if (current >= dimension.Limit)
{
return false;
}
continue;
}
stagedNewKeys++;
if (tracker.WindowCounts.Count + tracker.ConcurrencyCounts.Count + stagedNewKeys
> maxTrackedKeys)
{
return false;
}
}
return true;
}
private static void Release(TrackerState tracker, string[] keys)
{
lock (tracker.Gate)
{
foreach (string key in keys)
{
if (!tracker.ConcurrencyCounts.TryGetValue(key, out int current))
{
continue;
}
if (current <= 1)
{
tracker.ConcurrencyCounts.Remove(key);
}
else
{
tracker.ConcurrencyCounts[key] = current - 1;
}
}
}
}
private static bool DisposeAccepted(AbuseLease? lease)
{
lease?.Dispose();
return true;
}
private static bool IsLeaseCriticalOperation(string operation) => operation is
"RenewSessionLease" or "UpdateSession" or "DeleteSession";
private static string GetNetworkPrefix(IPAddress? address)
{
if (address is null)
{
return "unknown";
}
IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
byte[] bytes = normalized.GetAddressBytes();
if (bytes.Length == 4)
{
bytes[3] = 0;
return $"4:{Convert.ToHexString(bytes)}:24";
}
if (bytes.Length == 16)
{
Array.Clear(bytes, 7, 9);
return $"6:{Convert.ToHexString(bytes)}:56";
}
return "unknown";
}
private static IPAddress NormalizeAddress(IPAddress address) =>
address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
private readonly record struct RateDimension(string Key, int Limit);
private enum TrackerDomain
{
Http,
Udp,
}
internal sealed class TrackerState(DateTimeOffset windowStartedAt)
{
public object Gate { get; } = new();
public Dictionary<string, int> WindowCounts { get; } = new(StringComparer.Ordinal);
public Dictionary<string, int> ConcurrencyCounts { get; } = new(StringComparer.Ordinal);
public DateTimeOffset WindowStartedAt { get; set; } = windowStartedAt;
}
internal sealed class AbuseLease : IDisposable
{
private AbuseProtectionService? _owner;
private readonly TrackerState _tracker;
private readonly string[] _keys;
internal AbuseLease(
AbuseProtectionService owner,
TrackerState tracker,
string[] keys)
{
_owner = owner;
_tracker = tracker;
_keys = keys;
}
public void Dispose()
{
if (Interlocked.Exchange(ref _owner, null) is not null)
{
Release(_tracker, _keys);
}
}
}
}
@@ -0,0 +1,135 @@
using FinalFactory.Rendezvous.Contracts;
using Microsoft.AspNetCore.Http.Features;
namespace FinalFactory.Rendezvous.Server.Abuse;
internal sealed class HttpAbuseProtectionMiddleware(
RequestDelegate next,
AbuseProtectionService protection)
{
public async Task InvokeAsync(HttpContext context)
{
IHttpMaxRequestBodySizeFeature? bodySize =
context.Features.Get<IHttpMaxRequestBodySizeFeature>();
if (bodySize is { IsReadOnly: false })
{
bodySize.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes;
}
string operation = context.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()
?.EndpointName ?? "Unmatched";
bool healthEndpoint = operation is "GetLiveness" or "GetReadiness";
bool operatorEndpoint = operation is
"GetOperatorStatus"
or "RevokeOperatorListing"
or "RevokeOperatorPrincipal"
or "RevokeOperatorSigningKey"
or "BeginOperatorDrain";
if (operatorEndpoint
&& !protection.IsOperatorSourceAllowed(context.Connection.RemoteIpAddress))
{
bool deniedSourceAdmitted = protection.TryAcquireHttpIngress(
context.Connection.RemoteIpAddress,
"Unmatched",
out AbuseProtectionService.AbuseLease? deniedSourceLease,
out int deniedRetryAfterSeconds);
using (deniedSourceLease)
{
if (!deniedSourceAdmitted)
{
context.Response.Headers.RetryAfter = deniedRetryAfterSeconds.ToString(
System.Globalization.CultureInfo.InvariantCulture);
await WriteErrorAsync(
context,
StatusCodes.Status429TooManyRequests,
RendezvousErrorCode.RateLimited,
"The request rate limit was exceeded.",
deniedRetryAfterSeconds).ConfigureAwait(false);
return;
}
await WriteErrorAsync(
context,
StatusCodes.Status404NotFound,
RendezvousErrorCode.NotFound,
"The requested resource was not found.").ConfigureAwait(false);
}
return;
}
AbuseProtectionService.AbuseLease? lease;
int retryAfterSeconds;
bool acquired;
if (healthEndpoint)
{
acquired = protection.TryAcquireHealthIngress(
context.Connection.RemoteIpAddress,
out lease,
out retryAfterSeconds);
}
else if (operatorEndpoint)
{
acquired = protection.TryAcquireOperatorIngress(
context.Connection.RemoteIpAddress,
out lease,
out retryAfterSeconds);
}
else
{
acquired = protection.TryAcquireHttpIngress(
context.Connection.RemoteIpAddress,
operation,
out lease,
out retryAfterSeconds);
}
if (!acquired)
{
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(
System.Globalization.CultureInfo.InvariantCulture);
await WriteErrorAsync(
context,
StatusCodes.Status429TooManyRequests,
RendezvousErrorCode.RateLimited,
"The request rate limit was exceeded.",
retryAfterSeconds).ConfigureAwait(false);
return;
}
using (lease)
{
if (context.Request.ContentLength > ContractLimits.HttpRequestMaxBytes)
{
await WriteErrorAsync(
context,
StatusCodes.Status413PayloadTooLarge,
RendezvousErrorCode.InvalidRequest,
"The request body exceeds the supported size.").ConfigureAwait(false);
return;
}
await next(context).ConfigureAwait(false);
}
}
private static Task WriteErrorAsync(
HttpContext context,
int status,
RendezvousErrorCode code,
string message,
int? retryAfterSeconds = null)
{
context.Response.StatusCode = status;
return context.Response.WriteAsJsonAsync(
new ApiError
{
Code = code,
Message = message,
RetryAfterSeconds = retryAfterSeconds,
},
ContractJson.Options,
contentType: "application/json",
cancellationToken: context.RequestAborted);
}
}
@@ -0,0 +1,24 @@
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;
namespace FinalFactory.Rendezvous.Server.Abuse;
internal static class TrustedProxyForwarding
{
public static bool IsEnabled(AbuseProtectionOptions options) =>
options.TrustedProxyAddresses is { Length: > 0 };
public static void Configure(
ForwardedHeadersOptions forwarded,
AbuseProtectionOptions abuse)
{
forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
forwarded.ForwardLimit = 1;
forwarded.KnownProxies.Clear();
forwarded.KnownIPNetworks.Clear();
foreach (string address in abuse.TrustedProxyAddresses ?? [])
{
forwarded.KnownProxies.Add(IPAddress.Parse(address));
}
}
}
@@ -153,5 +153,6 @@ internal sealed class SessionBrowserService(
static item => item.Key, static item => item.Key,
static item => item.Value, static item => item.Value,
StringComparer.Ordinal), StringComparer.Ordinal),
DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback),
}; };
} }
@@ -0,0 +1,141 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Observability;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.ConnectionOutcomes;
internal sealed record ConnectionOutcomeServiceResult(
RendezvousErrorCode Error,
ReportConnectionOutcomeResponse? Value = null)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed class ConnectionOutcomeMetrics
{
private readonly object _gate = new();
private readonly Dictionary<(ConnectionOutcomeKind, ConnectionElapsedBucket), long> _counts = [];
private readonly RendezvousTelemetry? _telemetry;
public ConnectionOutcomeMetrics(RendezvousTelemetry? telemetry = null) =>
_telemetry = telemetry;
internal void Record(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
{
lock (_gate)
{
(ConnectionOutcomeKind, ConnectionElapsedBucket) key = (outcome, elapsedBucket);
_counts.TryGetValue(key, out long count);
_counts[key] = count + 1;
}
_telemetry?.RecordConnectionOutcome(outcome.ToString(), elapsedBucket.ToString());
}
internal long GetCount(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
{
lock (_gate)
{
return _counts.GetValueOrDefault((outcome, elapsedBucket));
}
}
}
internal sealed class ConnectionOutcomeService(
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
ConnectionOutcomeMetrics metrics)
{
internal ConnectionOutcomeServiceResult Report(
JoinAttemptId attemptId,
string? clientPunchCapability,
ReportConnectionOutcomeRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(
request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return new(version);
}
if (attemptId.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(clientPunchCapability)
|| !TryNormalizeReport(request, out ConnectionOutcomeKind outcome, out ConnectionElapsedBucket elapsedBucket)
|| !capabilities.TryFingerprint(
clientPunchCapability,
out SecretFingerprint capabilityFingerprint))
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<StoredConnectionOutcome> reported = store.ReportConnectionOutcome(new(
attemptId,
capabilityFingerprint,
outcome,
elapsedBucket), cancellationToken);
if (!reported.Succeeded)
{
return new(reported.Code.ToContractError());
}
if (!reported.IsIdempotentReplay)
{
metrics.Record(outcome, elapsedBucket);
}
return new(RendezvousErrorCode.None, new ReportConnectionOutcomeResponse
{
Accepted = true,
IsDuplicate = reported.IsIdempotentReplay,
});
}
private static bool TryNormalizeReport(
ReportConnectionOutcomeRequest request,
out ConnectionOutcomeKind outcome,
out ConnectionElapsedBucket elapsedBucket)
{
outcome = request.Outcome switch
{
ConnectionOutcomeKind.TimedOut => ConnectionOutcomeKind.PunchTimedOut,
ConnectionOutcomeKind.StaleHost => ConnectionOutcomeKind.NoHostPresence,
ConnectionOutcomeKind.TransportFailed => ConnectionOutcomeKind.TransportError,
_ => request.Outcome,
};
if (!ContractValidation.IsReportableConnectionOutcome(request.Outcome))
{
elapsedBucket = default;
return false;
}
if (Enum.IsDefined(request.ElapsedBucket))
{
elapsedBucket = request.ElapsedBucket;
return true;
}
#pragma warning disable CS0618 // Frozen v1 compatibility input; never retained at exact precision.
if (request.ElapsedBucket == default && request.ElapsedMilliseconds >= 0)
{
elapsedBucket = BucketElapsedMilliseconds(request.ElapsedMilliseconds);
return true;
}
#pragma warning restore CS0618
elapsedBucket = default;
return false;
}
private static ConnectionElapsedBucket BucketElapsedMilliseconds(int elapsedMilliseconds) =>
elapsedMilliseconds switch
{
< 1_000 => ConnectionElapsedBucket.UnderOneSecond,
< 5_000 => ConnectionElapsedBucket.OneToFiveSeconds,
< 15_000 => ConnectionElapsedBucket.FiveToFifteenSeconds,
< 30_000 => ConnectionElapsedBucket.FifteenToThirtySeconds,
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
};
}
@@ -1,6 +1,8 @@
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.JoinAttempts; 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;
@@ -11,8 +13,6 @@ namespace FinalFactory.Rendezvous.Server.Http;
internal static class ContractEndpoints internal static class ContractEndpoints
{ {
private const int NotImplementedStatus = StatusCodes.Status501NotImplemented;
public static IEndpointRouteBuilder MapRendezvousContractEndpoints( public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
this IEndpointRouteBuilder endpoints) this IEndpointRouteBuilder endpoints)
{ {
@@ -21,6 +21,7 @@ internal static class ContractEndpoints
.Accepts<RegisterSessionRequest>("application/json") .Accepts<RegisterSessionRequest>("application/json")
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created) .Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized) .Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden) .Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status409Conflict) .Produces<ApiError>(StatusCodes.Status409Conflict)
@@ -32,45 +33,54 @@ internal static class ContractEndpoints
.Accepts<RenewLeaseRequest>("application/json") .Accepts<RenewLeaseRequest>("application/json")
.Produces<RenewLeaseResponse>() .Produces<RenewLeaseResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized) .Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden) .Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict) .Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone) .Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("RenewSessionLease"); .WithName("RenewSessionLease");
sessions.MapPut("/{listingId}", UpdateSession) sessions.MapPut("/{listingId}", UpdateSession)
.Accepts<UpdateSessionRequest>("application/json") .Accepts<UpdateSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized) .Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden) .Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("UpdateSession"); .WithName("UpdateSession");
sessions.MapDelete("/{listingId}", DeleteSession) sessions.MapDelete("/{listingId}", DeleteSession)
.Accepts<DeleteSessionRequest>("application/json") .Accepts<DeleteSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized) .Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden) .Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("DeleteSession"); .WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions) sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>() .Produces<BrowseSessionsResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions"); .WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession) sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>() .Produces<GetSessionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("GetSession"); .WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>() .Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseHostJoinAttempts"); .WithName("BrowseHostJoinAttempts");
@@ -81,8 +91,10 @@ internal static class ContractEndpoints
.Accepts<CreateJoinAttemptRequest>("application/json") .Accepts<CreateJoinAttemptRequest>("application/json")
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created) .Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict) .Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests) .Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CreateJoinAttempt"); .WithName("CreateJoinAttempt");
@@ -90,12 +102,18 @@ internal static class ContractEndpoints
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CancelJoinAttempt"); .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>(StatusCodes.Status501NotImplemented) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("ReportConnectionOutcome"); .WithName("ReportConnectionOutcome");
return endpoints; return endpoints;
@@ -106,6 +124,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader, [FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials, [FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions, [FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock, [FromServices] IWallClock clock,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -119,13 +138,29 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext); return AuthenticationRequired(httpContext);
} }
SessionServiceResult<RegisterSessionResponse> result = sessions.Register( IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
principal!, if (!TryAcquireIdentity(
request, abuseProtection,
cancellationToken); httpContext,
return result.Succeeded && result.Value is not null "RegisterSession",
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value) Tenant(publisher.GameId, publisher.EnvironmentId),
: Error(result.Error); publisher.Subject,
null,
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
principal!,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
: Error(result.Error);
}
} }
private static IResult RenewLease( private static IResult RenewLease(
@@ -134,6 +169,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader, [FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials, [FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions, [FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock, [FromServices] IWallClock clock,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -147,14 +183,30 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext); return AuthenticationRequired(httpContext);
} }
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew( IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
principal!, if (!TryAcquireIdentity(
listingId, abuseProtection,
request, httpContext,
cancellationToken); "RenewSessionLease",
return result.Succeeded && result.Value is not null Tenant(publisher.GameId, publisher.EnvironmentId),
? Results.Ok(result.Value) publisher.Subject,
: Error(result.Error); listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
} }
private static IResult UpdateSession( private static IResult UpdateSession(
@@ -163,6 +215,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader, [FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials, [FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions, [FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock, [FromServices] IWallClock clock,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -176,12 +229,28 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext); return AuthenticationRequired(httpContext);
} }
SessionServiceResult<bool> result = sessions.Update( IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
principal!, if (!TryAcquireIdentity(
listingId, abuseProtection,
request, httpContext,
cancellationToken); "UpdateSession",
return result.Succeeded ? Results.NoContent() : Error(result.Error); Tenant(publisher.GameId, publisher.EnvironmentId),
publisher.Subject,
listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
SessionServiceResult<bool> result = sessions.Update(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
} }
private static IResult DeleteSession( private static IResult DeleteSession(
@@ -190,6 +259,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader, [FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials, [FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions, [FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock, [FromServices] IWallClock clock,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -203,12 +273,28 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext); return AuthenticationRequired(httpContext);
} }
SessionServiceResult<bool> result = sessions.Delete( IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
principal!, if (!TryAcquireIdentity(
listingId, abuseProtection,
request, httpContext,
cancellationToken); "DeleteSession",
return result.Succeeded ? Results.NoContent() : Error(result.Error); Tenant(publisher.GameId, publisher.EnvironmentId),
publisher.Subject,
listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
SessionServiceResult<bool> result = sessions.Delete(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
} }
private static IResult BrowseSessions( private static IResult BrowseSessions(
@@ -221,6 +307,8 @@ internal static class ContractEndpoints
[FromQuery] bool? excludeFull, [FromQuery] bool? excludeFull,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromServices] SessionBrowserService browser, [FromServices] SessionBrowserService browser,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!GameId.TryParse(gameId, out GameId parsedGameId) if (!GameId.TryParse(gameId, out GameId parsedGameId)
@@ -230,20 +318,35 @@ internal static class ContractEndpoints
return Error(RendezvousErrorCode.InvalidRequest); return Error(RendezvousErrorCode.InvalidRequest);
} }
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new() if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"BrowseSessions",
Tenant(parsedGameId, parsedEnvironmentId),
null,
null,
out AbuseProtectionService.AbuseLease? abuseLease))
{ {
ContractVersion = contractVersion, return RateLimited(httpContext);
GameId = parsedGameId, }
EnvironmentId = parsedEnvironmentId,
ProtocolVersion = protocolVersion, using (abuseLease)
RegionId = regionId is null ? null : new RegionId(regionId), {
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems, BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
ExcludeFull = excludeFull ?? false, {
Cursor = cursor, ContractVersion = contractVersion,
}, cancellationToken); GameId = parsedGameId,
return result.Succeeded && result.Value is not null EnvironmentId = parsedEnvironmentId,
? Results.Ok(result.Value) ProtocolVersion = protocolVersion,
: Error(result.Error); RegionId = regionId is null ? null : new RegionId(regionId),
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
ExcludeFull = excludeFull ?? false,
Cursor = cursor,
}, cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
} }
private static IResult GetSession( private static IResult GetSession(
@@ -253,6 +356,8 @@ internal static class ContractEndpoints
[FromQuery] string environmentId, [FromQuery] string environmentId,
[FromQuery] uint protocolVersion, [FromQuery] uint protocolVersion,
[FromServices] SessionBrowserService browser, [FromServices] SessionBrowserService browser,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None) if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
@@ -266,15 +371,30 @@ internal static class ContractEndpoints
return Error(RendezvousErrorCode.InvalidRequest); return Error(RendezvousErrorCode.InvalidRequest);
} }
BrowserServiceResult<GetSessionResponse> result = browser.Get( if (!TryAcquireIdentity(
listingId, abuseProtection,
parsedGameId, httpContext,
parsedEnvironmentId, "GetSession",
protocolVersion, Tenant(parsedGameId, parsedEnvironmentId),
cancellationToken); null,
return result.Succeeded && result.Value is not null listingId.ToString(),
? Results.Ok(result.Value) out AbuseProtectionService.AbuseLease? abuseLease))
: Error(result.Error); {
return RateLimited(httpContext);
}
using (abuseLease)
{
BrowserServiceResult<GetSessionResponse> result = browser.Get(
listingId,
parsedGameId,
parsedEnvironmentId,
protocolVersion,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
} }
private static IResult BrowseHostJoinAttempts( private static IResult BrowseHostJoinAttempts(
@@ -284,23 +404,41 @@ internal static class ContractEndpoints
[FromQuery] int? pageSize, [FromQuery] int? pageSize,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromServices] JoinAttemptService attempts, [FromServices] JoinAttemptService attempts,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost( if (!TryAcquireIdentity(
listingId, abuseProtection,
contractVersion, httpContext,
leaseToken, "BrowseHostJoinAttempts",
pageSize ?? ContractLimits.BrowserPageMaxItems, null,
cursor, AbuseProtectionService.FingerprintSecret(leaseToken ?? string.Empty),
cancellationToken); listingId.ToString(),
return result.Succeeded && result.Value is not null out AbuseProtectionService.AbuseLease? abuseLease))
? Results.Ok(result.Value) {
: Error(result.Error); return RateLimited(httpContext);
}
using (abuseLease)
{
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( private static IResult CreateJoinAttempt(
[FromBody] CreateJoinAttemptRequest request, [FromBody] CreateJoinAttemptRequest request,
[FromServices] JoinAttemptService attempts, [FromServices] JoinAttemptService attempts,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -310,40 +448,93 @@ internal static class ContractEndpoints
} }
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress); string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create( if (!TryAcquireIdentity(
clientSubject, abuseProtection,
request, httpContext,
cancellationToken); "CreateJoinAttempt",
return result.Succeeded && result.Value is not null Tenant(request.GameId, request.EnvironmentId),
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value) clientSubject,
: Error(result.Error); request.ListingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
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( private static IResult CancelJoinAttempt(
JoinAttemptId attemptId, JoinAttemptId attemptId,
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability, [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromServices] JoinAttemptService attempts, [FromServices] JoinAttemptService attempts,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
JoinAttemptServiceResult<bool> result = attempts.Cancel( if (!TryAcquireIdentity(
attemptId, abuseProtection,
clientPunchCapability, httpContext,
cancellationToken); "CancelJoinAttempt",
return result.Succeeded ? Results.NoContent() : Error(result.Error); null,
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
attemptId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
JoinAttemptServiceResult<bool> result = attempts.Cancel(
attemptId,
clientPunchCapability,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
} }
private static IResult ReportConnectionOutcome( private static IResult ReportConnectionOutcome(
JoinAttemptId attemptId, JoinAttemptId attemptId,
[FromBody] ReportConnectionOutcomeRequest request) => NotImplemented(); [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromBody] ReportConnectionOutcomeRequest request,
private static IResult NotImplemented() => Results.Json( [FromServices] ConnectionOutcomeService outcomes,
new ApiError [FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"ReportConnectionOutcome",
null,
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
attemptId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{ {
Code = RendezvousErrorCode.ServiceUnavailable, return RateLimited(httpContext);
Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.", }
},
ContractJson.Options, using (abuseLease)
statusCode: NotImplementedStatus); {
ConnectionOutcomeServiceResult result = outcomes.Report(
attemptId,
clientPunchCapability,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
}
private static bool TryAuthenticatePublisher( private static bool TryAuthenticatePublisher(
string? authorizationHeader, string? authorizationHeader,
@@ -370,11 +561,41 @@ internal static class ContractEndpoints
return true; return true;
} }
private static IResult Error(RendezvousErrorCode code) => Results.Json( private static bool TryAcquireIdentity(
AbuseProtectionService abuseProtection,
HttpContext httpContext,
string operation,
string? tenant,
string? principal,
string? resource,
out AbuseProtectionService.AbuseLease? lease)
{
if (abuseProtection.TryAcquireHttpIdentity(
operation,
httpContext.Connection.RemoteIpAddress,
tenant,
principal,
resource,
out lease,
out int retryAfterSeconds))
{
return true;
}
httpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString(
System.Globalization.CultureInfo.InvariantCulture);
return false;
}
private static string Tenant(GameId gameId, EnvironmentId environmentId) =>
$"{gameId.Value}/{environmentId.Value}";
private static IResult Error(RendezvousErrorCode code, int? retryAfterSeconds = null) => Results.Json(
new ApiError new ApiError
{ {
Code = code, Code = code,
Message = ErrorMessage(code), Message = ErrorMessage(code),
RetryAfterSeconds = retryAfterSeconds,
}, },
ContractJson.Options, ContractJson.Options,
statusCode: ErrorStatus(code)); statusCode: ErrorStatus(code));
@@ -385,13 +606,27 @@ internal static class ContractEndpoints
return Error(RendezvousErrorCode.AuthenticationRequired); return Error(RendezvousErrorCode.AuthenticationRequired);
} }
private static IResult RateLimited(HttpContext context)
{
int? retryAfterSeconds = int.TryParse(
context.Response.Headers.RetryAfter,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int parsed)
? Math.Clamp(parsed, 1, 60)
: null;
return Error(RendezvousErrorCode.RateLimited, retryAfterSeconds);
}
private static int ErrorStatus(RendezvousErrorCode code) => code switch private static int ErrorStatus(RendezvousErrorCode code) => code switch
{ {
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized, RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden, RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound, RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound,
RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict, RendezvousErrorCode.Conflict
RendezvousErrorCode.Expired => StatusCodes.Status410Gone, or RendezvousErrorCode.IncompatibleProtocol
or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
RendezvousErrorCode.Expired or RendezvousErrorCode.StaleHost => StatusCodes.Status410Gone,
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded => RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
StatusCodes.Status429TooManyRequests, StatusCodes.Status429TooManyRequests,
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
@@ -406,7 +641,9 @@ internal static class ContractEndpoints
RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.", RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.",
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.", RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
RendezvousErrorCode.Expired => "The session lease has expired.", RendezvousErrorCode.Expired => "The session lease has expired.",
RendezvousErrorCode.StaleHost => "The session has no fresh host presence.",
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.", RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
RendezvousErrorCode.RateLimited => "The request rate limit was exceeded.",
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.", RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.", RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.", RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
@@ -4,7 +4,8 @@ using Microsoft.AspNetCore.Diagnostics;
namespace FinalFactory.Rendezvous.Server.Http; namespace FinalFactory.Rendezvous.Server.Http;
internal sealed class RendezvousExceptionHandler : IExceptionHandler internal sealed partial class RendezvousExceptionHandler(
ILogger<RendezvousExceptionHandler> logger) : IExceptionHandler
{ {
public async ValueTask<bool> TryHandleAsync( public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, HttpContext httpContext,
@@ -17,21 +18,46 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler
} }
bool invalidRequest = exception is BadHttpRequestException or JsonException; bool invalidRequest = exception is BadHttpRequestException or JsonException;
httpContext.Response.StatusCode = invalidRequest bool payloadTooLarge = exception is BadHttpRequestException
? StatusCodes.Status400BadRequest {
: StatusCodes.Status500InternalServerError; StatusCode: StatusCodes.Status413PayloadTooLarge,
};
httpContext.Response.StatusCode = payloadTooLarge
? StatusCodes.Status413PayloadTooLarge
: invalidRequest
? StatusCodes.Status400BadRequest
: StatusCodes.Status500InternalServerError;
LogRequestFailure(
logger,
payloadTooLarge ? "payload-too-large" : invalidRequest ? "invalid-request" : "internal-error",
httpContext.Response.StatusCode,
httpContext.Response.Headers["X-Rendezvous-Correlation-ID"].ToString() is { Length: > 0 } value
? value
: "unavailable");
await httpContext.Response.WriteAsJsonAsync( await httpContext.Response.WriteAsJsonAsync(
new ApiError new ApiError
{ {
Code = invalidRequest Code = invalidRequest
? RendezvousErrorCode.InvalidRequest ? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.InternalError, : RendezvousErrorCode.InternalError,
Message = invalidRequest Message = payloadTooLarge
? "The request body, route, or query value is invalid." ? "The request body exceeds the supported size."
: "The service could not complete the request.", : invalidRequest
? "The request body, route, or query value is invalid."
: "The service could not complete the request.",
}, },
ContractJson.Options, ContractJson.Options,
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
return true; return true;
} }
[LoggerMessage(
EventId = 200,
Level = LogLevel.Warning,
Message = "Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}")]
private static partial void LogRequestFailure(
ILogger logger,
string failureKind,
int statusCode,
string correlationId);
} }
@@ -66,7 +66,15 @@ internal sealed class JoinAttemptService(
string derivationSalt = capabilities.CreateDerivationSalt(); string derivationSalt = capabilities.CreateDerivationSalt();
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt); string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt); string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt); JoinAttemptId attemptId = new(capabilities.DeriveGuid(
"join-attempt-id",
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
string connectionTicket = NatIntroductionTokenCodec.Encode(
attemptId,
Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt));
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket) if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint) || !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint) || !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
@@ -75,12 +83,6 @@ internal sealed class JoinAttemptService(
throw new InvalidOperationException("Derived join credentials violated their contract invariants."); 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( MediationHandle mediationHandle = new(capabilities.DeriveGuid(
"join-mediation-handle", "join-mediation-handle",
clientSubject, clientSubject,
@@ -126,7 +128,10 @@ internal sealed class JoinAttemptService(
AttemptId = persisted.AttemptId, AttemptId = persisted.AttemptId,
MediationHandle = persisted.MediationHandle, MediationHandle = persisted.MediationHandle,
ClientPunchCapability = clientCapability, ClientPunchCapability = clientCapability,
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
CreateConnectionTicket(persisted)),
ExpiresAt = persisted.ExpiresAt, ExpiresAt = persisted.ExpiresAt,
DedicatedFallback = StoredListing.CopyEndpoint(persisted.DedicatedFallback),
}); });
} }
@@ -203,7 +208,7 @@ internal sealed class JoinAttemptService(
StoredJoinAttempt attempt) StoredJoinAttempt attempt)
{ {
ArgumentNullException.ThrowIfNull(attempt); ArgumentNullException.ThrowIfNull(attempt);
if (!attempt.IntroductionConsumed) if (!attempt.IntroductionConsumed || attempt.IsCancelled)
{ {
return new(RendezvousErrorCode.Conflict); return new(RendezvousErrorCode.Conflict);
} }
@@ -213,12 +218,7 @@ internal sealed class JoinAttemptService(
return new(RendezvousErrorCode.Expired); return new(RendezvousErrorCode.Expired);
} }
string ticket = Derive( string ticket = CreateConnectionTicket(attempt);
"connection-ticket",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt);
if (!ContractValidation.IsConnectionTicketValid(ticket) if (!ContractValidation.IsConnectionTicketValid(ticket)
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint) || !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|| fingerprint != attempt.ConnectionTicketFingerprint) || fingerprint != attempt.ConnectionTicketFingerprint)
@@ -249,10 +249,23 @@ internal sealed class JoinAttemptService(
AttemptId = attempt.AttemptId, AttemptId = attempt.AttemptId,
MediationHandle = attempt.MediationHandle, MediationHandle = attempt.MediationHandle,
HostPunchCapability = capability, HostPunchCapability = capability,
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
CreateConnectionTicket(attempt)),
IsCancelled = attempt.IsCancelled,
ExpiresAt = attempt.ExpiresAt, ExpiresAt = attempt.ExpiresAt,
}; };
} }
private string CreateConnectionTicket(StoredJoinAttempt attempt) =>
NatIntroductionTokenCodec.Encode(
attempt.AttemptId,
Derive(
"connection-ticket",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt));
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request) private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
{ {
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion); RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
@@ -316,6 +329,9 @@ internal sealed class JoinAttemptService(
ContractValidation.IsCapabilityValid(hostCapability) ContractValidation.IsCapabilityValid(hostCapability)
&& ContractValidation.IsCapabilityValid(clientCapability) && ContractValidation.IsCapabilityValid(clientCapability)
&& ContractValidation.IsConnectionTicketValid(ticket) && ContractValidation.IsConnectionTicketValid(ticket)
&& hostCapability.Length == ContractLimits.DerivedCredentialCharacters
&& clientCapability.Length == ContractLimits.DerivedCredentialCharacters
&& ticket.Length == ContractLimits.DerivedCredentialCharacters
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters && hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters; && clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace FinalFactory.Rendezvous.Server.Observability;
internal sealed class AuditOptions
{
public const string SectionName = "Rendezvous:Audit";
[Range(100, 100_000)]
public int MaxEntries { get; set; } = 10_000;
[Range(1, 30)]
public int RetentionDays { get; set; } = 30;
}
@@ -0,0 +1,134 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Observability;
internal sealed partial class AuditTrail
{
private readonly object _gate = new();
private readonly LinkedList<AuditEntry> _entries = [];
private readonly AuditOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AuditTrail> _logger;
private readonly RendezvousTelemetry _telemetry;
public AuditTrail(
IOptions<AuditOptions> options,
ILogger<AuditTrail> logger,
RendezvousTelemetry telemetry,
TimeProvider? timeProvider = null)
{
_options = options.Value;
_logger = logger;
_telemetry = telemetry;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public void Record(
string actorSubject,
string action,
string result,
string targetKind,
string targetIdentifier,
string correlationId)
{
DateTimeOffset now = _timeProvider.GetUtcNow();
AuditEntry entry = new(
now,
Fingerprint(actorSubject),
action,
result,
targetKind,
Fingerprint(targetIdentifier),
correlationId);
lock (_gate)
{
PurgeExpired(now);
while (_entries.Count >= _options.MaxEntries)
{
_entries.RemoveFirst();
}
_entries.AddLast(entry);
}
_telemetry.RecordAudit(action, result);
LogOperatorAction(
_logger,
entry.Timestamp,
entry.ActorFingerprint,
action,
result,
targetKind,
entry.TargetFingerprint,
correlationId);
}
public IReadOnlyDictionary<string, long> GetAggregateCounts()
{
lock (_gate)
{
PurgeExpired(_timeProvider.GetUtcNow());
return _entries
.GroupBy(static entry => $"{entry.Action}:{entry.Result}", StringComparer.Ordinal)
.ToDictionary(
static group => group.Key,
static group => (long)group.Count(),
StringComparer.Ordinal);
}
}
internal IReadOnlyList<AuditEntry> GetEntriesForTests()
{
lock (_gate)
{
PurgeExpired(_timeProvider.GetUtcNow());
return _entries.ToArray();
}
}
private void PurgeExpired(DateTimeOffset now)
{
DateTimeOffset oldest = now.AddDays(-_options.RetentionDays);
while (_entries.First is { Value.Timestamp: var timestamp }
&& timestamp < oldest)
{
_entries.RemoveFirst();
}
}
private static string Fingerprint(string value)
{
byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return Convert.ToHexString(digest.AsSpan(0, 12));
}
[LoggerMessage(
EventId = 100,
Level = LogLevel.Information,
Message = "Operator audit at {Timestamp}: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}")]
private static partial void LogOperatorAction(
ILogger logger,
DateTimeOffset timestamp,
string actorFingerprint,
string action,
string result,
string targetKind,
string targetFingerprint,
string correlationId);
}
internal sealed record AuditEntry(
DateTimeOffset Timestamp,
string ActorFingerprint,
string Action,
string Result,
string TargetKind,
string TargetFingerprint,
string CorrelationId)
{
public override string ToString() =>
$"[AuditEntry {Action}/{Result}; actor and target fingerprinted]";
}
@@ -0,0 +1,30 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Observability;
internal static class HealthEndpoints
{
public static IEndpointRouteBuilder MapRendezvousHealthEndpoints(
this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/health/live",
static () => Results.Ok(new HealthResponse { Status = "live" }))
.Produces<HealthResponse>()
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.WithName("GetLiveness")
.WithTags("Health");
endpoints.MapGet(
"/health/ready",
static (RendezvousReadiness readiness) =>
!readiness.GetSnapshot().IsReady
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
: Results.Ok(new HealthResponse { Status = "ready" }))
.Produces<HealthResponse>()
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces(StatusCodes.Status503ServiceUnavailable)
.WithName("GetReadiness")
.WithTags("Health");
return endpoints;
}
}
@@ -0,0 +1,41 @@
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Observability;
internal sealed class RendezvousReadiness(
UdpMediatorService mediator,
ProvisioningReadiness provisioning,
IEphemeralRendezvousStore state,
IOptions<UdpMediatorOptions> udpOptions)
{
public ReadinessSnapshot GetSnapshot()
{
bool ipv6Required = !string.IsNullOrWhiteSpace(udpOptions.Value.Ipv6ListenAddress);
return new ReadinessSnapshot(
HttpListenerReady: true,
UdpIpv4ListenerReady: mediator.LocalEndpoint is not null,
UdpIpv6ListenerReady: !ipv6Required || mediator.LocalIpv6Endpoint is not null,
ProvisioningReady: provisioning.IsReady,
StoreAvailable: state.IsAvailable,
Draining: state.IsDraining);
}
}
internal sealed record ReadinessSnapshot(
bool HttpListenerReady,
bool UdpIpv4ListenerReady,
bool UdpIpv6ListenerReady,
bool ProvisioningReady,
bool StoreAvailable,
bool Draining)
{
public bool IsReady => HttpListenerReady
&& UdpIpv4ListenerReady
&& UdpIpv6ListenerReady
&& ProvisioningReady
&& StoreAvailable
&& !Draining;
}
@@ -0,0 +1,126 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Observability;
internal sealed class RendezvousTelemetry : IDisposable
{
public const string MeterName = "FinalFactory.Rendezvous";
public const string ActivitySourceName = "FinalFactory.Rendezvous.Server";
private readonly InMemoryEphemeralRendezvousStore _store;
private readonly Meter _meter = new(MeterName, "1.0.0");
private readonly ActivitySource _activities = new(ActivitySourceName, "1.0.0");
private readonly Counter<long> _httpRequests;
private readonly Histogram<double> _httpDuration;
private readonly Counter<long> _udpResults;
private readonly Histogram<double> _udpDuration;
private readonly Counter<long> _limiterDrops;
private readonly Counter<long> _auditEvents;
private readonly Counter<long> _connectionOutcomes;
private readonly Counter<long> _operatorAuthentication;
private readonly Histogram<double> _pairingLatency;
public RendezvousTelemetry(InMemoryEphemeralRendezvousStore store)
{
_store = store;
_httpRequests = _meter.CreateCounter<long>("rendezvous.http.requests");
_httpDuration = _meter.CreateHistogram<double>(
"rendezvous.http.duration",
"ms");
_udpResults = _meter.CreateCounter<long>("rendezvous.udp.results");
_udpDuration = _meter.CreateHistogram<double>(
"rendezvous.udp.duration",
"ms");
_limiterDrops = _meter.CreateCounter<long>("rendezvous.limiter.drops");
_auditEvents = _meter.CreateCounter<long>("rendezvous.audit.events");
_connectionOutcomes = _meter.CreateCounter<long>("rendezvous.connection.outcomes");
_operatorAuthentication = _meter.CreateCounter<long>("rendezvous.operator.authentication");
_pairingLatency = _meter.CreateHistogram<double>(
"rendezvous.pairing.latency",
"ms");
_meter.CreateObservableGauge(
"rendezvous.store.active_listings",
() => _store.GetMetricsSnapshot().ActiveListings);
_meter.CreateObservableGauge(
"rendezvous.store.active_leases",
() => _store.GetMetricsSnapshot().ActiveListings);
_meter.CreateObservableGauge(
"rendezvous.store.active_attempts",
() => _store.GetMetricsSnapshot().ActiveJoinAttempts);
_meter.CreateObservableGauge(
"rendezvous.queue.depth",
() => _store.GetMetricsSnapshot().ActiveJoinAttempts);
_meter.CreateObservableGauge(
"rendezvous.store.replay_markers",
() => _store.GetMetricsSnapshot().ReplayMarkers);
_meter.CreateObservableGauge(
"rendezvous.store.available",
() => _store.GetMetricsSnapshot().IsAvailable ? 1 : 0);
_meter.CreateObservableCounter(
"rendezvous.store.expiry_churn",
() => _store.GetMetricsSnapshot().ExpiryChurn);
}
public Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) =>
_activities.StartActivity(name, kind);
public void RecordHttp(string operation, int statusCode, double elapsedMilliseconds)
{
TagList tags = new()
{
{ "operation", operation },
{ "status_code", statusCode },
};
_httpRequests.Add(1, tags);
_httpDuration.Record(elapsedMilliseconds, tags);
}
public void RecordUdp(string operation, string result, double elapsedMilliseconds)
{
TagList tags = new()
{
{ "operation", operation },
{ "result", result },
};
_udpResults.Add(1, tags);
_udpDuration.Record(elapsedMilliseconds, tags);
}
public void RecordLimiterDrop(string transport, string partition) =>
_limiterDrops.Add(1, new TagList
{
{ "transport", transport },
{ "partition", partition },
});
public void RecordAudit(string action, string result) =>
_auditEvents.Add(1, new TagList
{
{ "action", action },
{ "result", result },
});
public void RecordConnectionOutcome(string outcome, string elapsedBucket) =>
_connectionOutcomes.Add(1, new TagList
{
{ "outcome", outcome },
{ "elapsed_bucket", elapsedBucket },
});
public void RecordOperatorAuthentication(string result) =>
_operatorAuthentication.Add(1, new TagList
{
{ "result", result },
});
public void RecordPairingLatency(double elapsedMilliseconds) =>
_pairingLatency.Record(elapsedMilliseconds);
public void Dispose()
{
_activities.Dispose();
_meter.Dispose();
}
}
@@ -0,0 +1,32 @@
using System.Diagnostics;
namespace FinalFactory.Rendezvous.Server.Observability;
internal sealed class TelemetryMiddleware(
RequestDelegate next,
RendezvousTelemetry telemetry)
{
public async Task InvokeAsync(HttpContext context)
{
string operation = context.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()
?.EndpointName ?? "Unmatched";
long started = Stopwatch.GetTimestamp();
using Activity? activity = telemetry.StartActivity(
$"HTTP {operation}",
ActivityKind.Server);
string correlationId = activity?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
context.Response.Headers["X-Rendezvous-Correlation-ID"] = correlationId;
activity?.SetTag("rendezvous.operation", operation);
try
{
await next(context).ConfigureAwait(false);
}
finally
{
telemetry.RecordHttp(
operation,
context.Response.StatusCode,
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
}
}
@@ -0,0 +1,428 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Observability;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
using Microsoft.AspNetCore.Mvc;
namespace FinalFactory.Rendezvous.Server.Operations;
internal static class OperatorEndpoints
{
private const string CorrelationHeader = "X-Rendezvous-Correlation-ID";
public static IEndpointRouteBuilder MapOperatorEndpoints(this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder group = endpoints.MapGroup("/v1/operator").WithTags("Operator");
group.MapGet("/status", GetStatus)
.Produces<OperatorStatusResponse>()
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.WithName("GetOperatorStatus");
group.MapPost("/listings/revoke", RevokeListing)
.Accepts<RevokeListingRequest>("application/json")
.Produces<OperatorActionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("RevokeOperatorListing");
group.MapPost("/principals/revoke", RevokePrincipal)
.Accepts<RevokePrincipalRequest>("application/json")
.Produces<OperatorActionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("RevokeOperatorPrincipal");
group.MapPost("/keys/revoke", RevokeSigningKey)
.Accepts<RevokeSigningKeyRequest>("application/json")
.Produces<OperatorActionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.WithName("RevokeOperatorSigningKey");
group.MapPost("/drain", BeginDrain)
.Accepts<BeginDrainRequest>("application/json")
.Produces<OperatorActionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.WithName("BeginOperatorDrain");
return endpoints;
}
private static IResult GetStatus(
[FromHeader(Name = "Authorization")] string? authorization,
[FromServices] PrincipalCredentialService credentials,
[FromServices] IWallClock clock,
[FromServices] OperatorService service,
[FromServices] AuditTrail audit,
[FromServices] RendezvousTelemetry telemetry,
HttpContext context)
{
if (!TryAuthorize(
authorization,
OperatorPermission.ReadPolicy,
"inspect-status",
credentials,
clock,
audit,
telemetry,
context,
out OperatorPrincipal? principal,
out IResult? failure))
{
return failure!;
}
OperatorStatusResponse response = service.GetStatus();
audit.Record(
principal!.Subject,
"inspect-status",
"succeeded",
"service",
"rendezvous",
Correlation(context));
return Results.Ok(response);
}
private static IResult RevokeListing(
[FromBody] RevokeListingRequest request,
[FromHeader(Name = "Authorization")] string? authorization,
[FromServices] PrincipalCredentialService credentials,
[FromServices] IWallClock clock,
[FromServices] OperatorService service,
[FromServices] AuditTrail audit,
[FromServices] RendezvousTelemetry telemetry,
HttpContext context,
CancellationToken cancellationToken)
{
if (!TryAuthorize(
authorization,
OperatorPermission.RevokePublisher,
"revoke-listing",
credentials,
clock,
audit,
telemetry,
context,
out OperatorPrincipal? principal,
out IResult? failure))
{
return failure!;
}
bool valid = SessionListingId.TryParse(request.ListingId, out SessionListingId listingId);
if (!valid || !string.Equals(request.ListingId, request.ConfirmListingId, StringComparison.Ordinal))
{
AuditRejected(audit, principal!, "revoke-listing", "listing", request.ListingId, context);
return BadRequest("A valid listing ID and an exact repeated confirmation are required.");
}
StoreResult<bool> result = service.RevokeListing(listingId, cancellationToken);
return StoreActionResult(
result.Code,
audit,
principal!,
"revoke-listing",
"listing",
request.ListingId,
context,
affectedResources: result.Succeeded ? 1 : null);
}
private static IResult RevokePrincipal(
[FromBody] RevokePrincipalRequest request,
[FromHeader(Name = "Authorization")] string? authorization,
[FromServices] PrincipalCredentialService credentials,
[FromServices] IWallClock clock,
[FromServices] OperatorService service,
[FromServices] AuditTrail audit,
[FromServices] RendezvousTelemetry telemetry,
HttpContext context,
CancellationToken cancellationToken)
{
if (!TryAuthorize(
authorization,
OperatorPermission.RevokePublisher,
"revoke-principal",
credentials,
clock,
audit,
telemetry,
context,
out OperatorPrincipal? principal,
out IResult? failure))
{
return failure!;
}
bool safeSubject = request.Subject is { Length: > 0 and <= 128 }
&& request.Subject.All(static character => character is >= '!' and <= '~');
if (!safeSubject
|| !string.Equals(request.Subject, request.ConfirmSubject, StringComparison.Ordinal)
|| request.LifetimeSeconds is < 1 or > 600)
{
AuditRejected(audit, principal!, "revoke-principal", "principal", request.Subject, context);
return BadRequest("A valid subject, exact repeated confirmation, and 1-600 second lifetime are required.");
}
StoreResult<int> result = service.RevokePrincipal(
request.Subject,
TimeSpan.FromSeconds(request.LifetimeSeconds),
cancellationToken);
return StoreActionResult(
result.Code,
audit,
principal!,
"revoke-principal",
"principal",
request.Subject,
context,
result.Value);
}
private static IResult RevokeSigningKey(
[FromBody] RevokeSigningKeyRequest request,
[FromHeader(Name = "Authorization")] string? authorization,
[FromServices] PrincipalCredentialService credentials,
[FromServices] IWallClock clock,
[FromServices] OperatorService service,
[FromServices] AuditTrail audit,
[FromServices] RendezvousTelemetry telemetry,
HttpContext context)
{
if (!TryAuthorize(
authorization,
OperatorPermission.RotateKeys,
"revoke-signing-key",
credentials,
clock,
audit,
telemetry,
context,
out OperatorPrincipal? principal,
out IResult? failure))
{
return failure!;
}
bool safeKeyId = request.KeyId is { Length: > 0 and <= 64 }
&& request.KeyId.All(static character => character is
>= 'A' and <= 'Z'
or >= 'a' and <= 'z'
or >= '0' and <= '9'
or '-'
or '_');
if (!safeKeyId || !string.Equals(request.KeyId, request.ConfirmKeyId, StringComparison.Ordinal))
{
AuditRejected(audit, principal!, "revoke-signing-key", "signing-key", request.KeyId, context);
return BadRequest("A valid key ID and an exact repeated confirmation are required.");
}
bool revoked = service.RevokeSigningKey(request.KeyId);
string result = revoked ? "succeeded" : "not-found";
audit.Record(
principal!.Subject,
"revoke-signing-key",
result,
"signing-key",
request.KeyId,
Correlation(context));
return revoked
? Results.Ok(new OperatorActionResponse { Status = "completed" })
: Error(RendezvousErrorCode.NotFound, "The requested resource was not found.");
}
private static IResult BeginDrain(
[FromBody] BeginDrainRequest request,
[FromHeader(Name = "Authorization")] string? authorization,
[FromServices] PrincipalCredentialService credentials,
[FromServices] IWallClock clock,
[FromServices] OperatorService service,
[FromServices] AuditTrail audit,
[FromServices] RendezvousTelemetry telemetry,
HttpContext context,
CancellationToken cancellationToken)
{
if (!TryAuthorize(
authorization,
OperatorPermission.ManagePolicy,
"begin-drain",
credentials,
clock,
audit,
telemetry,
context,
out OperatorPrincipal? principal,
out IResult? failure))
{
return failure!;
}
if (!string.Equals(request.Confirmation, "DRAIN", StringComparison.Ordinal))
{
AuditRejected(audit, principal!, "begin-drain", "service", "rendezvous", context);
return BadRequest("The confirmation value must be exactly 'DRAIN'.");
}
service.BeginDrain(cancellationToken);
audit.Record(
principal!.Subject,
"begin-drain",
"succeeded",
"service",
"rendezvous",
Correlation(context));
return Results.Ok(new OperatorActionResponse { Status = "draining" });
}
private static bool TryAuthorize(
string? authorization,
OperatorPermission requiredPermission,
string operation,
PrincipalCredentialService credentials,
IWallClock clock,
AuditTrail audit,
RendezvousTelemetry telemetry,
HttpContext context,
out OperatorPrincipal? principal,
out IResult? failure)
{
principal = null;
failure = null;
const string prefix = "Bearer ";
if (authorization is null
|| !authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
telemetry.RecordOperatorAuthentication("rejected");
failure = AuthenticationRequired(context);
return false;
}
CredentialValidationResult validation = credentials.Validate(
authorization[prefix.Length..],
clock.UtcNow);
if (!validation.IsValid || validation.Principal is not OperatorPrincipal candidate)
{
telemetry.RecordOperatorAuthentication("rejected");
failure = AuthenticationRequired(context);
return false;
}
if (!candidate.Permissions.Contains(requiredPermission))
{
telemetry.RecordOperatorAuthentication("forbidden");
audit.Record(
candidate.Subject,
operation,
"forbidden",
"operator-operation",
operation,
Correlation(context));
failure = Error(RendezvousErrorCode.Forbidden, "The operator is not authorized for this operation.");
return false;
}
telemetry.RecordOperatorAuthentication("accepted");
principal = candidate;
return true;
}
private static IResult StoreActionResult(
StoreResultCode code,
AuditTrail audit,
OperatorPrincipal principal,
string action,
string targetKind,
string targetIdentifier,
HttpContext context,
int? affectedResources)
{
string auditResult = code == StoreResultCode.Success
? "succeeded"
: code.ToString().ToLowerInvariant();
audit.Record(
principal.Subject,
action,
auditResult,
targetKind,
targetIdentifier,
Correlation(context));
return code switch
{
StoreResultCode.Success => Results.Ok(new OperatorActionResponse
{
Status = "completed",
AffectedResources = affectedResources,
}),
StoreResultCode.NotFound => Error(
RendezvousErrorCode.NotFound,
"The requested resource was not found."),
StoreResultCode.CapacityExceeded => Error(
RendezvousErrorCode.CapacityExceeded,
"The operation could not be retained within the configured capacity."),
StoreResultCode.ServiceUnavailable or StoreResultCode.Draining => Error(
RendezvousErrorCode.ServiceUnavailable,
"The service is not available for this operation."),
_ => Error(RendezvousErrorCode.Conflict, "The operation could not be completed."),
};
}
private static void AuditRejected(
AuditTrail audit,
OperatorPrincipal principal,
string action,
string targetKind,
string? targetIdentifier,
HttpContext context) => audit.Record(
principal.Subject,
action,
"rejected",
targetKind,
targetIdentifier ?? string.Empty,
Correlation(context));
private static string Correlation(HttpContext context) =>
context.Response.Headers[CorrelationHeader].ToString() is { Length: > 0 } value
? value
: "unavailable";
private static IResult AuthenticationRequired(HttpContext context)
{
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"operator\"";
return Error(
RendezvousErrorCode.AuthenticationRequired,
"A valid operator bearer credential is required.");
}
private static IResult BadRequest(string message) => Error(RendezvousErrorCode.InvalidRequest, message);
private static IResult Error(RendezvousErrorCode code, string message) => Results.Json(
new ApiError { Code = code, Message = message },
ContractJson.Options,
statusCode: code switch
{
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound,
RendezvousErrorCode.CapacityExceeded => StatusCodes.Status429TooManyRequests,
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
RendezvousErrorCode.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest,
});
}
@@ -0,0 +1,82 @@
namespace FinalFactory.Rendezvous.Server.Operations;
internal sealed record OperatorStatusResponse
{
public required string Status { get; init; }
public required OperatorReadinessResponse Readiness { get; init; }
public required OperatorStoreResponse Store { get; init; }
public required IReadOnlyList<OperatorTenantResponse> Tenants { get; init; }
public required IReadOnlyList<OperatorSigningKeyResponse> SigningKeys { get; init; }
public required IReadOnlyDictionary<string, long> AuditCounts { get; init; }
}
internal sealed record OperatorReadinessResponse
{
public required bool HttpListener { get; init; }
public required bool UdpIpv4Listener { get; init; }
public required bool UdpIpv6Listener { get; init; }
public required bool Provisioning { get; init; }
public required bool Store { get; init; }
public required bool Draining { get; init; }
}
internal sealed record OperatorStoreResponse
{
public required int ActiveListings { get; init; }
public required int FreshPresenceBindings { get; init; }
public required int ActiveJoinAttempts { get; init; }
public required int RetainedOutcomeReports { get; init; }
public required int ReplayMarkers { get; init; }
public required int PrincipalRevocations { get; init; }
public required int IdempotencyEntries { get; init; }
public required long MaintenanceSweeps { get; init; }
public required long ExpiryChurn { get; init; }
}
internal sealed record OperatorTenantResponse
{
public required string GameId { get; init; }
public required string EnvironmentId { get; init; }
public required string Status { get; init; }
}
internal sealed record OperatorSigningKeyResponse
{
public required string KeyId { get; init; }
public required string Status { get; init; }
public required DateTimeOffset SignUntil { get; init; }
public required DateTimeOffset VerifyUntil { get; init; }
public string? GameId { get; init; }
public string? EnvironmentId { get; init; }
public required IReadOnlyList<string> CredentialKinds { get; init; }
}
internal sealed record OperatorActionResponse
{
public required string Status { get; init; }
public int? AffectedResources { get; init; }
}
internal sealed record RevokeListingRequest
{
public required string ListingId { get; init; }
public required string ConfirmListingId { get; init; }
}
internal sealed record RevokePrincipalRequest
{
public required string Subject { get; init; }
public required string ConfirmSubject { get; init; }
public required int LifetimeSeconds { get; init; }
}
internal sealed record RevokeSigningKeyRequest
{
public required string KeyId { get; init; }
public required string ConfirmKeyId { get; init; }
}
internal sealed record BeginDrainRequest
{
public required string Confirmation { get; init; }
}
@@ -0,0 +1,81 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Observability;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Operations;
internal sealed class OperatorService(
InMemoryEphemeralRendezvousStore store,
ProvisioningRuntime provisioning,
RendezvousReadiness readiness,
AuditTrail audit,
IWallClock clock)
{
public OperatorStatusResponse GetStatus()
{
ReadinessSnapshot readinessSnapshot = readiness.GetSnapshot();
EphemeralStoreSnapshot storeSnapshot = store.GetSnapshot();
return new OperatorStatusResponse
{
Status = readinessSnapshot.IsReady ? "ready" : "not-ready",
Readiness = new OperatorReadinessResponse
{
HttpListener = readinessSnapshot.HttpListenerReady,
UdpIpv4Listener = readinessSnapshot.UdpIpv4ListenerReady,
UdpIpv6Listener = readinessSnapshot.UdpIpv6ListenerReady,
Provisioning = readinessSnapshot.ProvisioningReady,
Store = readinessSnapshot.StoreAvailable,
Draining = readinessSnapshot.Draining,
},
Store = new OperatorStoreResponse
{
ActiveListings = storeSnapshot.ActiveListings,
FreshPresenceBindings = storeSnapshot.FreshPresenceBindings,
ActiveJoinAttempts = storeSnapshot.ActiveJoinAttempts,
RetainedOutcomeReports = storeSnapshot.RetainedOutcomeReports,
ReplayMarkers = storeSnapshot.ReplayMarkers,
PrincipalRevocations = storeSnapshot.PrincipalRevocations,
IdempotencyEntries = storeSnapshot.IdempotencyEntries,
MaintenanceSweeps = storeSnapshot.MaintenanceSweeps,
ExpiryChurn = storeSnapshot.ExpiryChurn,
},
Tenants = provisioning.Policies.EnabledPolicies
.OrderBy(static policy => policy.GameId.Value, StringComparer.Ordinal)
.ThenBy(static policy => policy.EnvironmentId.Value, StringComparer.Ordinal)
.Select(static policy => new OperatorTenantResponse
{
GameId = policy.GameId.Value,
EnvironmentId = policy.EnvironmentId.Value,
Status = "enabled",
})
.ToArray(),
SigningKeys = provisioning.SigningKeys.GetStatuses(clock.UtcNow)
.Select(static key => new OperatorSigningKeyResponse
{
KeyId = key.KeyId,
Status = key.Status,
SignUntil = key.SignUntil,
VerifyUntil = key.VerifyUntil,
GameId = key.GameId,
EnvironmentId = key.EnvironmentId,
CredentialKinds = key.CredentialKinds,
})
.ToArray(),
AuditCounts = audit.GetAggregateCounts(),
};
}
public StoreResult<bool> RevokeListing(
SessionListingId listingId,
CancellationToken cancellationToken) => store.RevokeListing(listingId, cancellationToken);
public StoreResult<int> RevokePrincipal(
string subject,
TimeSpan lifetime,
CancellationToken cancellationToken) => store.RevokePrincipal(subject, lifetime, cancellationToken);
public bool RevokeSigningKey(string keyId) => provisioning.SigningKeys.Revoke(keyId);
public void BeginDrain(CancellationToken cancellationToken) => store.BeginDrain(cancellationToken);
}
+173 -24
View File
@@ -1,15 +1,23 @@
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Observability;
using FinalFactory.Rendezvous.Server.Operations;
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;
using FinalFactory.Rendezvous.Server.Transport; using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.OpenApi; using Microsoft.OpenApi;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Logging.AddFilter(
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
LogLevel.None);
bool isOpenApiGeneration = string.Equals( bool isOpenApiGeneration = string.Equals(
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name, System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
"GetDocument.Insider", "GetDocument.Insider",
@@ -50,6 +58,22 @@ builder.Services.AddOpenApi("v1", static options =>
BearerFormat = "rv1 publisher credential", BearerFormat = "rv1 publisher credential",
Description = "Tenant-scoped publisher credential issued during game provisioning.", Description = "Tenant-scoped publisher credential issued during game provisioning.",
}; };
const string attemptSchemeName = "JoinAttemptCapability";
document.Components.SecuritySchemes[attemptSchemeName] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
Name = "X-Rendezvous-Client-Punch-Capability",
In = ParameterLocation.Header,
Description = "Attempt-scoped client capability returned only to the joining caller.",
};
const string operatorSchemeName = "OperatorBearer";
document.Components.SecuritySchemes[operatorSchemeName] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "rv1 operator credential",
Description = "Operator-only credential with an explicit permission set.",
};
HashSet<string> securedOperations = new(StringComparer.Ordinal) HashSet<string> securedOperations = new(StringComparer.Ordinal)
{ {
@@ -58,7 +82,17 @@ builder.Services.AddOpenApi("v1", static options =>
"UpdateSession", "UpdateSession",
"DeleteSession", "DeleteSession",
}; };
HashSet<string> operatorOperations = new(StringComparer.Ordinal)
{
"GetOperatorStatus",
"RevokeOperatorListing",
"RevokeOperatorPrincipal",
"RevokeOperatorSigningKey",
"BeginOperatorDrain",
};
OpenApiSecuritySchemeReference reference = new(schemeName, document, null); OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
OpenApiSecuritySchemeReference attemptReference = new(attemptSchemeName, document, null);
OpenApiSecuritySchemeReference operatorReference = new(operatorSchemeName, document, null);
foreach (OpenApiPathItem path in document.Paths.Values) foreach (OpenApiPathItem path in document.Paths.Values)
{ {
if (path.Operations is null) if (path.Operations is null)
@@ -75,6 +109,71 @@ builder.Services.AddOpenApi("v1", static options =>
[reference] = [], [reference] = [],
}); });
} }
foreach (OpenApiOperation operation in path.Operations.Values.Where(
operation => operation.OperationId is
"CancelJoinAttempt" or "ReportConnectionOutcome"))
{
operation.Security ??= [];
operation.Security.Add(new OpenApiSecurityRequirement
{
[attemptReference] = [],
});
}
foreach (OpenApiOperation operation in path.Operations.Values.Where(
operation => operatorOperations.Contains(
operation.OperationId ?? string.Empty)))
{
operation.Security ??= [];
operation.Security.Add(new OpenApiSecurityRequirement
{
[operatorReference] = [],
});
}
foreach (OpenApiOperation operation in path.Operations.Values)
{
if (operation.Responses is null)
{
continue;
}
foreach ((string status, IOpenApiResponse response) in operation.Responses)
{
if (response is not OpenApiResponse concreteResponse)
{
continue;
}
concreteResponse.Headers ??=
new Dictionary<string, IOpenApiHeader>(StringComparer.OrdinalIgnoreCase);
concreteResponse.Headers["X-Rendezvous-Correlation-ID"] = new OpenApiHeader
{
Description = "Safe request correlation identifier generated by the service.",
Schema = new OpenApiSchema
{
Type = JsonSchemaType.String,
},
};
if (string.Equals(
status,
StatusCodes.Status429TooManyRequests.ToString(
System.Globalization.CultureInfo.InvariantCulture),
StringComparison.Ordinal))
{
concreteResponse.Headers["Retry-After"] = new OpenApiHeader
{
Description = "Whole seconds before the caller should retry (1-60).",
Schema = new OpenApiSchema
{
Type = JsonSchemaType.Integer,
Format = "int32",
},
};
}
}
}
} }
return Task.CompletedTask; return Task.CompletedTask;
@@ -86,6 +185,55 @@ builder.Services.Configure<RouteHandlerOptions>(static options =>
options.ThrowOnBadRequest = true); options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>(); builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.WebHost.ConfigureKestrel(static options =>
options.Limits.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes);
builder.Services
.AddOptions<AbuseProtectionOptions>()
.BindConfiguration(AbuseProtectionOptions.SectionName)
.ValidateDataAnnotations()
.Validate(
options => options.HttpOptionalRequestsPerWindow
< options.HttpGlobalRequestsPerWindow,
"The optional HTTP request budget must leave global capacity for lease operations.")
.Validate(
options => options.HttpOptionalConcurrency < options.HttpGlobalConcurrency,
"The optional HTTP concurrency budget must leave global capacity for lease operations.")
.Validate(
options => options.HttpOptionalIpPrefixRequestsPerWindow
< options.HttpIpPrefixRequestsPerWindow,
"The optional HTTP source budget must leave capacity for lease operations.")
.Validate(
options => options.HttpOptionalIpPrefixConcurrency
< options.HttpIpPrefixConcurrency,
"The optional HTTP source concurrency must leave capacity for lease operations.")
.Validate(
options => options.CriticalTrackedKeyReserve >= 16
&& options.UdpTrackedKeyLimit + options.CriticalTrackedKeyReserve
< options.MaxTrackedKeys,
"The tracked-key reserve must leave at least 16 keys for critical operations.")
.Validate(
options => options.TrustedProxyAddresses is { Length: <= 32 } addresses
&& addresses.All(
static value => IPAddress.TryParse(value, out _)),
"Trusted proxy addresses must contain at most 32 literal IP addresses.")
.Validate(
options => options.OperatorAllowedAddresses is { Length: <= 32 } addresses
&& addresses.All(
static value => IPAddress.TryParse(value, out _)),
"Operator allowed addresses must contain at most 32 literal IP addresses.")
.ValidateOnStart();
builder.Services.AddSingleton<AbuseProtectionService>();
builder.Services
.AddOptions<AuditOptions>()
.BindConfiguration(AuditOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
AbuseProtectionOptions configuredAbuseProtection = builder.Configuration
.GetSection(AbuseProtectionOptions.SectionName)
.Get<AbuseProtectionOptions>() ?? new AbuseProtectionOptions();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
TrustedProxyForwarding.Configure(options, configuredAbuseProtection));
SystemRendezvousClock rendezvousClock = new(); SystemRendezvousClock rendezvousClock = new();
EphemeralStoreOptions stateOptions = new(); EphemeralStoreOptions stateOptions = new();
@@ -93,8 +241,13 @@ InMemoryEphemeralRendezvousStore stateStore = new(
stateOptions, stateOptions,
rendezvousClock, rendezvousClock,
rendezvousClock); rendezvousClock);
builder.Services.AddSingleton(stateStore);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore); builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
builder.Services.AddSingleton<IWallClock>(rendezvousClock); builder.Services.AddSingleton<IWallClock>(rendezvousClock);
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
builder.Services.AddSingleton<RendezvousTelemetry>();
builder.Services.AddSingleton<AuditTrail>();
builder.Services.AddSingleton<RendezvousReadiness>();
if (isOpenApiGeneration) if (isOpenApiGeneration)
{ {
@@ -125,6 +278,9 @@ else
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>(); builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>(); builder.Services.AddSingleton<JoinAttemptService>();
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
builder.Services.AddSingleton<ConnectionOutcomeService>();
builder.Services.AddSingleton<OperatorService>();
builder.Services.AddSingleton(new ProvisioningReadiness(true)); builder.Services.AddSingleton(new ProvisioningReadiness(true));
} }
@@ -133,12 +289,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>());
} }
@@ -146,31 +309,17 @@ if (!isOpenApiGeneration)
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain()); app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
{
app.UseForwardedHeaders();
}
app.UseMiddleware<TelemetryMiddleware>();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapOpenApi(); app.MapOpenApi();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
app.MapGet( app.MapOperatorEndpoints();
"/health/live", app.MapRendezvousHealthEndpoints();
static () => Results.Ok(new HealthResponse { Status = "live" }))
.Produces<HealthResponse>()
.WithName("GetLiveness")
.WithTags("Health");
app.MapGet(
"/health/ready",
static (
UdpMediatorService mediator,
ProvisioningReadiness provisioning,
IEphemeralRendezvousStore state) =>
mediator.LocalEndpoint is null
|| !provisioning.IsReady
|| !state.IsAvailable
|| state.IsDraining
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
: Results.Ok(new HealthResponse { Status = "ready" }))
.Produces<HealthResponse>()
.Produces(StatusCodes.Status503ServiceUnavailable)
.WithName("GetReadiness")
.WithTags("Health");
await app.RunAsync(); await app.RunAsync();
@@ -142,8 +142,36 @@ internal sealed class SigningKeyRing : IDisposable
return VerificationKeyLookup.Available; return VerificationKeyLookup.Available;
} }
public bool Revoke(string keyId) => public bool Revoke(string keyId)
_keys.ContainsKey(keyId) && _runtimeRevocations.TryAdd(keyId, 0); {
if (!_keys.ContainsKey(keyId))
{
return false;
}
_runtimeRevocations.TryAdd(keyId, 0);
return true;
}
public IReadOnlyList<SigningKeyStatus> GetStatuses(DateTimeOffset now) => _keys.Values
.OrderBy(static key => key.KeyId, StringComparer.Ordinal)
.Select(key => new SigningKeyStatus(
key.KeyId,
IsRevoked(key)
? "revoked"
: now < key.NotBefore
? "not-yet-valid"
: now < key.SignUntil
? "signing"
: now < key.VerifyUntil
? "verify-only"
: "retired",
key.SignUntil,
key.VerifyUntil,
key.GameId,
key.EnvironmentId,
key.CredentialKinds.Select(static kind => kind.ToString()).Order().ToArray()))
.ToArray();
public void Dispose() public void Dispose()
{ {
@@ -210,6 +238,15 @@ internal sealed class SigningKeyRing : IDisposable
} }
} }
internal sealed record SigningKeyStatus(
string KeyId,
string Status,
DateTimeOffset SignUntil,
DateTimeOffset VerifyUntil,
string? GameId,
string? EnvironmentId,
IReadOnlyList<string> CredentialKinds);
internal sealed class SigningKey : IDisposable internal sealed class SigningKey : IDisposable
{ {
private byte[]? _material; private byte[]? _material;
@@ -55,6 +55,11 @@ internal sealed class SessionLeaseService(
} }
AuthorizedPublisherContext context = authorized.Context; AuthorizedPublisherContext context = authorized.Context;
if (!IsFallbackAllowed(context.Policy, request.DedicatedFallback))
{
return new(RendezvousErrorCode.Forbidden);
}
string requestFingerprint = ComputeRegistrationFingerprint(request); string requestFingerprint = ComputeRegistrationFingerprint(request);
string derivationSalt = capabilities.CreateDerivationSalt(); string derivationSalt = capabilities.CreateDerivationSalt();
string leaseToken = capabilities.DeriveCapability( string leaseToken = capabilities.DeriveCapability(
@@ -119,6 +124,7 @@ internal sealed class SessionLeaseService(
CurrentPlayers = request.Capacity.CurrentPlayers, CurrentPlayers = request.Capacity.CurrentPlayers,
MaximumPlayers = request.Capacity.MaximumPlayers, MaximumPlayers = request.Capacity.MaximumPlayers,
Metadata = request.Metadata, Metadata = request.Metadata,
DedicatedFallback = request.DedicatedFallback,
LeaseFingerprint = leaseFingerprint, LeaseFingerprint = leaseFingerprint,
HostPresenceHandle = presenceHandle, HostPresenceHandle = presenceHandle,
HostPresenceFingerprint = presenceFingerprint, HostPresenceFingerprint = presenceFingerprint,
@@ -235,10 +241,14 @@ internal sealed class SessionLeaseService(
StoredListing ownedListing = listing!; StoredListing ownedListing = listing!;
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata); PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
if (!authorized.IsAllowed) if (!authorized.IsAllowed || authorized.Context is null)
{ {
return new(MapAuthorization(authorized.Error)); return new(MapAuthorization(authorized.Error));
} }
if (!IsFallbackAllowed(authorized.Context.Policy, request.DedicatedFallback))
{
return new(RendezvousErrorCode.Forbidden);
}
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint); capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
StoreResult<StoredListing> updated = store.UpdateListing(new( StoreResult<StoredListing> updated = store.UpdateListing(new(
@@ -250,7 +260,8 @@ internal sealed class SessionLeaseService(
request.DisplayName, request.DisplayName,
request.Capacity.CurrentPlayers, request.Capacity.CurrentPlayers,
request.Capacity.MaximumPlayers, request.Capacity.MaximumPlayers,
request.Metadata), cancellationToken); request.Metadata,
request.DedicatedFallback), cancellationToken);
return updated.Succeeded return updated.Succeeded
? new(RendezvousErrorCode.None, true) ? new(RendezvousErrorCode.None, true)
: new(updated.Code.ToContractError()); : new(updated.Code.ToContractError());
@@ -341,6 +352,9 @@ internal sealed class SessionLeaseService(
metadata, metadata,
clock.UtcNow); clock.UtcNow);
private static bool IsFallbackAllowed(GamePolicy policy, NetworkEndpoint? fallback) =>
fallback is null || policy.FallbackPolicy == FallbackPolicyMode.DedicatedEndpointAllowed;
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request) private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
{ {
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion); RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
@@ -359,6 +373,8 @@ internal sealed class SessionLeaseService(
|| !Enum.IsDefined(request.Visibility) || !Enum.IsDefined(request.Visibility)
|| !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata) || !ContractValidation.IsMetadataValid(request.Metadata)
|| request.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
? RendezvousErrorCode.InvalidRequest ? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None; : RendezvousErrorCode.None;
} }
@@ -375,6 +391,8 @@ internal sealed class SessionLeaseService(
|| !ContractValidation.IsDisplayNameValid(request.DisplayName) || !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata) || !ContractValidation.IsMetadataValid(request.Metadata)
|| request.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
? RendezvousErrorCode.InvalidRequest ? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None; : RendezvousErrorCode.None;
} }
@@ -424,6 +442,14 @@ internal sealed class SessionLeaseService(
Metadata = request.Metadata Metadata = request.Metadata
.OrderBy(static item => item.Key, StringComparer.Ordinal) .OrderBy(static item => item.Key, StringComparer.Ordinal)
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal), .ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
DedicatedFallback = request.DedicatedFallback is null
? null
: new NetworkEndpoint
{
AddressFamily = request.DedicatedFallback.AddressFamily,
Address = request.DedicatedFallback.Address,
Port = request.DedicatedFallback.Port,
},
}; };
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options); byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
byte[] digest = SHA256.HashData(encoded); byte[] digest = SHA256.HashData(encoded);
@@ -29,6 +29,7 @@ internal sealed record EphemeralStoreOptions
public int MaxListings { get; init; } = 25_000; public int MaxListings { get; init; } = 25_000;
public int MaxPresenceBindings { get; init; } = 25_000; public int MaxPresenceBindings { get; init; } = 25_000;
public int MaxJoinAttempts { get; init; } = 10_000; public int MaxJoinAttempts { get; init; } = 10_000;
public int MaxOutcomeReports { get; init; } = 35_000;
public int MaxReplayEntries { get; init; } = 30_000; public int MaxReplayEntries { get; init; } = 30_000;
public int MaxRevocations { get; init; } = 10_000; public int MaxRevocations { get; init; } = 10_000;
public int MaxIdempotencyEntries { get; init; } = 35_000; public int MaxIdempotencyEntries { get; init; } = 35_000;
@@ -45,6 +46,7 @@ internal sealed record EphemeralStoreOptions
RequirePositive(MaxListings, nameof(MaxListings)); RequirePositive(MaxListings, nameof(MaxListings));
RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings)); RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings));
RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts)); RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts));
RequirePositive(MaxOutcomeReports, nameof(MaxOutcomeReports));
RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries)); RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries));
RequirePositive(MaxRevocations, nameof(MaxRevocations)); RequirePositive(MaxRevocations, nameof(MaxRevocations));
RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries)); RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries));
@@ -173,6 +175,7 @@ internal sealed record ListingDefinition
public required int CurrentPlayers { get; init; } public required int CurrentPlayers { get; init; }
public required int MaximumPlayers { get; init; } public required int MaximumPlayers { get; init; }
public required IReadOnlyDictionary<string, string> Metadata { get; init; } public required IReadOnlyDictionary<string, string> Metadata { get; init; }
public NetworkEndpoint? DedicatedFallback { get; init; }
public required SecretFingerprint LeaseFingerprint { get; init; } public required SecretFingerprint LeaseFingerprint { get; init; }
public required MediationHandle HostPresenceHandle { get; init; } public required MediationHandle HostPresenceHandle { get; init; }
public required SecretFingerprint HostPresenceFingerprint { get; init; } public required SecretFingerprint HostPresenceFingerprint { get; init; }
@@ -189,7 +192,17 @@ internal sealed record StoredListing
public static ListingDefinition Freeze(ListingDefinition source) => source with public static ListingDefinition Freeze(ListingDefinition source) => source with
{ {
Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal), Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal),
DedicatedFallback = CopyEndpoint(source.DedicatedFallback),
}; };
internal static NetworkEndpoint? CopyEndpoint(NetworkEndpoint? endpoint) => endpoint is null
? null
: new NetworkEndpoint
{
AddressFamily = endpoint.AddressFamily,
Address = endpoint.Address,
Port = endpoint.Port,
};
} }
internal sealed record CreateListingCommand( internal sealed record CreateListingCommand(
@@ -214,7 +227,8 @@ internal sealed record UpdateListingCommand(
string DisplayName, string DisplayName,
int CurrentPlayers, int CurrentPlayers,
int MaximumPlayers, int MaximumPlayers,
IReadOnlyDictionary<string, string> Metadata); IReadOnlyDictionary<string, string> Metadata,
NetworkEndpoint? DedicatedFallback);
internal sealed record DeleteListingCommand( internal sealed record DeleteListingCommand(
SessionListingId ListingId, SessionListingId ListingId,
@@ -257,6 +271,7 @@ internal sealed record CreateJoinAttemptCommand
public required SecretFingerprint ClientCapabilityFingerprint { get; init; } public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
public required SecretFingerprint ConnectionTicketFingerprint { get; init; } public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
public required string CapabilityDerivationSalt { get; init; } public required string CapabilityDerivationSalt { get; init; }
public NetworkEndpoint? DedicatedFallback { get; init; }
public int ScopeAttemptLimit { get; init; } = int.MaxValue; public int ScopeAttemptLimit { get; init; } = int.MaxValue;
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]"; public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
@@ -280,12 +295,15 @@ internal sealed record StoredJoinAttempt
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 SecretFingerprint ConnectionTicketFingerprint { get; init; }
public NetworkEndpoint? DedicatedFallback { get; init; }
public required DateTimeOffset ExpiresAt { get; init; } public required DateTimeOffset ExpiresAt { get; init; }
public required TimeSpan CreatedAtMonotonic { get; init; }
public required DateTimeOffset ConnectionTicketExpiresAt { 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 required bool ConnectionTicketConsumed { get; init; }
public required bool IsCancelled { get; init; }
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]"; public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
} }
@@ -315,6 +333,16 @@ internal sealed record CancelJoinAttemptCommand(
JoinAttemptId AttemptId, JoinAttemptId AttemptId,
SecretFingerprint ClientCapabilityFingerprint); SecretFingerprint ClientCapabilityFingerprint);
internal sealed record ReportConnectionOutcomeCommand(
JoinAttemptId AttemptId,
SecretFingerprint ClientCapabilityFingerprint,
ConnectionOutcomeKind Outcome,
ConnectionElapsedBucket ElapsedBucket);
internal sealed record StoredConnectionOutcome(
ConnectionOutcomeKind Outcome,
ConnectionElapsedBucket ElapsedBucket);
internal sealed record ConsumeConnectionTicketCommand( internal sealed record ConsumeConnectionTicketCommand(
JoinAttemptId AttemptId, JoinAttemptId AttemptId,
SecretFingerprint ConnectionTicketFingerprint); SecretFingerprint ConnectionTicketFingerprint);
@@ -335,6 +363,8 @@ internal enum StoreResultCode
Draining = 6, Draining = 6,
ReplayRejected = 7, ReplayRejected = 7,
ServiceUnavailable = 8, ServiceUnavailable = 8,
StaleHost = 9,
IncompatibleProtocol = 10,
} }
internal sealed record StoreResult<T>(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false) internal sealed record StoreResult<T>(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false)
@@ -358,6 +388,7 @@ internal interface IEphemeralRendezvousStore
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<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default); StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredConnectionOutcome> ReportConnectionOutcome(ReportConnectionOutcomeCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default); StoreResult<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> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, 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;
@@ -14,12 +15,18 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = []; private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = []; private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = []; private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
private readonly Dictionary<JoinAttemptId, OutcomeReportEntry> _outcomeReports = [];
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = []; private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal); private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
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 long _expiryChurn;
private bool _available = true; private bool _available = true;
private EphemeralStoreSnapshot? _metricsSnapshot;
private TimeSpan _metricsSnapshotAt = TimeSpan.MinValue;
public InMemoryEphemeralRendezvousStore( public InMemoryEphemeralRendezvousStore(
EphemeralStoreOptions options, EphemeralStoreOptions options,
@@ -38,6 +45,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
{ {
@@ -61,6 +69,50 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
} }
internal EphemeralStoreSnapshot GetSnapshot()
{
lock (_gate)
{
TimeSpan now = _monotonicClock.Elapsed;
Cleanup(now);
EphemeralStoreSnapshot snapshot = CreateSnapshot();
_metricsSnapshot = snapshot;
_metricsSnapshotAt = now;
return snapshot;
}
}
internal EphemeralStoreSnapshot GetMetricsSnapshot()
{
lock (_gate)
{
TimeSpan now = _monotonicClock.Elapsed;
if (_metricsSnapshot is null
|| now < _metricsSnapshotAt
|| now - _metricsSnapshotAt >= TimeSpan.FromMilliseconds(100))
{
Cleanup(now);
_metricsSnapshot = CreateSnapshot();
_metricsSnapshotAt = now;
}
return _metricsSnapshot;
}
}
private EphemeralStoreSnapshot CreateSnapshot() => new(
_listings.Count,
_presence.Count,
_attempts.Count,
_outcomeReports.Count,
_replay.Count,
_revocations.Count,
_idempotency.Count,
_maintenanceSweepCount,
_expiryChurn,
_available,
_drainDeadline.HasValue);
public StoreResult<StoredListing> CreateListing( public StoreResult<StoredListing> CreateListing(
CreateListingCommand command, CreateListingCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now => CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
@@ -179,7 +231,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers || command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|| command.CurrentPlayers < 0 || command.CurrentPlayers < 0
|| command.CurrentPlayers > command.MaximumPlayers || command.CurrentPlayers > command.MaximumPlayers
|| !ContractValidation.IsMetadataValid(command.Metadata)) || !ContractValidation.IsMetadataValid(command.Metadata)
|| command.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
{ {
throw new ArgumentException("Listing update invariants are invalid.", nameof(command)); throw new ArgumentException("Listing update invariants are invalid.", nameof(command));
} }
@@ -209,6 +263,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
CurrentPlayers = command.CurrentPlayers, CurrentPlayers = command.CurrentPlayers,
MaximumPlayers = command.MaximumPlayers, MaximumPlayers = command.MaximumPlayers,
Metadata = command.Metadata, Metadata = command.Metadata,
DedicatedFallback = command.DedicatedFallback,
}); });
entry.Version++; entry.Version++;
return new(StoreResultCode.Success, Snapshot(entry)); return new(StoreResultCode.Success, Snapshot(entry));
@@ -270,6 +325,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 +341,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,
@@ -357,14 +413,26 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? listing) if (!_listings.TryGetValue(command.ListingId, out ListingEntry? listing)
|| listing.Definition.Scope != command.Scope || listing.Definition.Scope != command.Scope)
|| listing.Definition.ProtocolVersion != command.ProtocolVersion
|| !_presence.ContainsKey(listing.Definition.HostPresenceHandle))
{ {
return new(StoreResultCode.NotFound); return new(StoreResultCode.NotFound);
} }
if (listing.Definition.ProtocolVersion != command.ProtocolVersion)
{
return new(StoreResultCode.IncompatibleProtocol);
}
if (!_presence.ContainsKey(listing.Definition.HostPresenceHandle))
{
return new(StoreResultCode.StaleHost);
}
command = command with
{
DedicatedFallback = StoredListing.CopyEndpoint(listing.Definition.DedicatedFallback),
};
if (_attempts.Count >= _options.MaxJoinAttempts if (_attempts.Count >= _options.MaxJoinAttempts
|| _outcomeReports.Count >= _options.MaxOutcomeReports
|| _idempotency.Count >= _options.MaxIdempotencyEntries || _idempotency.Count >= _options.MaxIdempotencyEntries
|| _attempts.Values.Count(entry => entry.Command.Scope == command.Scope) || _attempts.Values.Count(entry => entry.Command.Scope == command.Scope)
>= command.ScopeAttemptLimit) >= command.ScopeAttemptLimit)
@@ -379,9 +447,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
AttemptEntry attempt = new( AttemptEntry attempt = new(
command, command,
now,
now + _options.JoinAttemptLifetime, now + _options.JoinAttemptLifetime,
WallDeadline(now, _options.JoinAttemptLifetime)); WallDeadline(now, _options.JoinAttemptLifetime));
_attempts.Add(command.AttemptId, attempt); _attempts.Add(command.AttemptId, attempt);
_outcomeReports.Add(command.AttemptId, new(
command.ListingId,
command.ClientSubject,
command.ClientCapabilityFingerprint,
now + _options.JoinAttemptLifetime + _options.IdempotencyLifetime));
_attemptHandles.Add(command.MediationHandle, command.AttemptId); _attemptHandles.Add(command.MediationHandle, command.AttemptId);
_idempotency.Add(idempotencyKey, new( _idempotency.Add(idempotencyKey, new(
command.RequestFingerprint, command.RequestFingerprint,
@@ -415,7 +489,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values
.Where(entry => entry.Command.ListingId == query.ListingId .Where(entry => entry.Command.ListingId == query.ListingId
&& !entry.IntroductionConsumed && (!entry.IntroductionConsumed || entry.IsCancelled)
&& (!query.AfterAttemptId.HasValue && (!query.AfterAttemptId.HasValue
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0)) || entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
.OrderBy(static entry => entry.Command.AttemptId.Value) .OrderBy(static entry => entry.Command.AttemptId.Value)
@@ -446,13 +520,54 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.NotFound); return new(StoreResultCode.NotFound);
} }
RemoveAttempt(command.AttemptId); if (attempt.IsCancelled)
{
return new(StoreResultCode.Success, true, true);
}
attempt.IsCancelled = true;
return new(StoreResultCode.Success, true); return new(StoreResultCode.Success, true);
}, cancellationToken); }, cancellationToken);
public StoreResult<StoredConnectionOutcome> ReportConnectionOutcome(
ReportConnectionOutcomeCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredConnectionOutcome>(_ =>
{
ArgumentNullException.ThrowIfNull(command);
if (command.AttemptId.Value == Guid.Empty
|| !command.ClientCapabilityFingerprint.IsValid
|| !ContractValidation.IsReportableConnectionOutcome(command.Outcome)
|| !Enum.IsDefined(command.ElapsedBucket))
{
throw new ArgumentException("Connection outcome invariants are invalid.", nameof(command));
}
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_outcomeReports.TryGetValue(command.AttemptId, out OutcomeReportEntry? entry)
|| entry.ClientCapabilityFingerprint != command.ClientCapabilityFingerprint)
{
return new(StoreResultCode.NotFound);
}
StoredConnectionOutcome reported = new(command.Outcome, command.ElapsedBucket);
if (entry.Outcome is not null)
{
return entry.Outcome == reported
? new(StoreResultCode.Success, entry.Outcome, true)
: new(StoreResultCode.ReplayRejected);
}
entry.Outcome = reported;
return new(StoreResultCode.Success, reported);
}, 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
@@ -469,7 +584,14 @@ 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
|| attempt.IsCancelled)
{
return new(StoreResultCode.NotFound);
}
if (!HasFreshHostPresence(attempt, now))
{ {
return new(StoreResultCode.NotFound); return new(StoreResultCode.NotFound);
} }
@@ -503,7 +625,7 @@ 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,
@@ -515,7 +637,14 @@ 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
|| attempt.IsCancelled)
{
return new(StoreResultCode.NotFound);
}
if (!HasFreshHostPresence(attempt, now))
{ {
return new(StoreResultCode.NotFound); return new(StoreResultCode.NotFound);
} }
@@ -540,7 +669,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
Snapshot(attempt), Snapshot(attempt),
attempt.HostEndpoint, attempt.HostEndpoint,
attempt.ClientEndpoint)); attempt.ClientEndpoint));
}, cancellationToken); }, cancellationToken, eagerCleanup: false);
public StoreResult<bool> ConsumeConnectionTicket( public StoreResult<bool> ConsumeConnectionTicket(
ConsumeConnectionTicketCommand command, ConsumeConnectionTicketCommand command,
@@ -568,6 +697,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.Conflict); return new(StoreResultCode.Conflict);
} }
if (attempt.IsCancelled)
{
return new(StoreResultCode.Conflict);
}
if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now) if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now)
{ {
return new(StoreResultCode.Expired); return new(StoreResultCode.Expired);
@@ -643,6 +777,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.CapacityExceeded); return new(StoreResultCode.CapacityExceeded);
} }
int activeResourcesBefore = _listings.Count
+ _presence.Count
+ _attempts.Count
+ _outcomeReports.Count;
_revocations[subject] = now + lifetime; _revocations[subject] = now + lifetime;
SessionListingId[] listings = _listings SessionListingId[] listings = _listings
.Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal)) .Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal))
@@ -652,6 +790,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
.Where(item => string.Equals(item.Value.Command.ClientSubject, subject, StringComparison.Ordinal)) .Where(item => string.Equals(item.Value.Command.ClientSubject, subject, StringComparison.Ordinal))
.Select(static item => item.Key) .Select(static item => item.Key)
.ToArray(); .ToArray();
JoinAttemptId[] outcomeReports = _outcomeReports
.Where(item => string.Equals(item.Value.ClientSubject, subject, StringComparison.Ordinal))
.Select(static item => item.Key)
.ToArray();
foreach (SessionListingId listingId in listings) foreach (SessionListingId listingId in listings)
{ {
RemoveListing(listingId); RemoveListing(listingId);
@@ -661,8 +803,16 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
{ {
RemoveAttempt(attemptId); RemoveAttempt(attemptId);
} }
foreach (JoinAttemptId attemptId in outcomeReports)
{
_outcomeReports.Remove(attemptId);
}
return new(StoreResultCode.Success, listings.Length + attempts.Length); int activeResourcesAfter = _listings.Count
+ _presence.Count
+ _attempts.Count
+ _outcomeReports.Count;
return new(StoreResultCode.Success, activeResourcesBefore - activeResourcesAfter);
}, cancellationToken); }, cancellationToken);
public void BeginDrain(CancellationToken cancellationToken = default) public void BeginDrain(CancellationToken cancellationToken = default)
@@ -674,6 +824,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
if (!_drainDeadline.HasValue) if (!_drainDeadline.HasValue)
{ {
_drainDeadline = _monotonicClock.Elapsed + _options.GracefulDrainLifetime; _drainDeadline = _monotonicClock.Elapsed + _options.GracefulDrainLifetime;
_metricsSnapshot = null;
} }
} }
} }
@@ -684,21 +835,44 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
{ {
_available = false; _available = false;
ClearActiveState(); ClearActiveState();
_metricsSnapshot = null;
} }
} }
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;
Cleanup(now); // Authenticated UDP duplicates need O(1) store work. Their operations
return operation(now); // check exact resource deadlines and amortize physical expiry removal.
bool drainExpired = _drainDeadline is TimeSpan drainDeadline
&& now >= drainDeadline;
if (drainExpired || eagerCleanup || now >= _nextUdpMaintenance)
{
Cleanup(now);
_nextUdpMaintenance = now + UdpMaintenanceInterval;
}
StoreResult<T> result = operation(now);
_metricsSnapshot = null;
return result;
} }
} }
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)
@@ -718,41 +892,60 @@ 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();
} }
RemoveExpired(_revocations, now); _expiryChurn += RemoveExpired(_revocations, now);
RemoveExpired(_replay, now); _expiryChurn += RemoveExpired(_replay, now);
foreach (string key in _idempotency string[] expiredIdempotency = _idempotency
.Where(item => item.Value.Deadline <= now) .Where(item => item.Value.Deadline <= now)
.Select(static item => item.Key) .Select(static item => item.Key)
.ToArray()) .ToArray();
_expiryChurn += expiredIdempotency.Length;
foreach (string key in expiredIdempotency)
{ {
_idempotency.Remove(key); _idempotency.Remove(key);
} }
foreach (MediationHandle handle in _presence MediationHandle[] expiredPresence = _presence
.Where(item => item.Value.Deadline <= now) .Where(item => item.Value.Deadline <= now)
.Select(static item => item.Key) .Select(static item => item.Key)
.ToArray()) .ToArray();
_expiryChurn += expiredPresence.Length;
foreach (MediationHandle handle in expiredPresence)
{ {
_presence.Remove(handle); _presence.Remove(handle);
} }
foreach (JoinAttemptId attemptId in _attempts JoinAttemptId[] expiredAttempts = _attempts
.Where(item => item.Value.Deadline <= now) .Where(item => item.Value.Deadline <= now)
.Select(static item => item.Key) .Select(static item => item.Key)
.ToArray()) .ToArray();
_expiryChurn += expiredAttempts.Length;
foreach (JoinAttemptId attemptId in expiredAttempts)
{ {
RemoveAttempt(attemptId); RemoveAttempt(attemptId);
} }
foreach (SessionListingId listingId in _listings JoinAttemptId[] expiredOutcomes = _outcomeReports
.Where(item => item.Value.LeaseDeadline <= now) .Where(item => item.Value.Deadline <= now)
.Select(static item => item.Key) .Select(static item => item.Key)
.ToArray()) .ToArray();
_expiryChurn += expiredOutcomes.Length;
foreach (JoinAttemptId attemptId in expiredOutcomes)
{
_outcomeReports.Remove(attemptId);
}
SessionListingId[] expiredListings = _listings
.Where(item => item.Value.LeaseDeadline <= now)
.Select(static item => item.Key)
.ToArray();
_expiryChurn += expiredListings.Length;
foreach (SessionListingId listingId in expiredListings)
{ {
RemoveListing(listingId); RemoveListing(listingId);
} }
@@ -765,6 +958,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
_presenceHandles.Clear(); _presenceHandles.Clear();
_presence.Clear(); _presence.Clear();
_attempts.Clear(); _attempts.Clear();
_outcomeReports.Clear();
_attemptHandles.Clear(); _attemptHandles.Clear();
_idempotency.Clear(); _idempotency.Clear();
_replay.Clear(); _replay.Clear();
@@ -787,6 +981,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
{ {
RemoveAttempt(attemptId); RemoveAttempt(attemptId);
} }
foreach (JoinAttemptId attemptId in _outcomeReports
.Where(item => item.Value.ListingId == listingId)
.Select(static item => item.Key)
.ToArray())
{
_outcomeReports.Remove(attemptId);
}
} }
private void RemoveAttempt(JoinAttemptId attemptId) private void RemoveAttempt(JoinAttemptId attemptId)
@@ -825,23 +1028,29 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint, HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint,
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint, ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint, ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
DedicatedFallback = StoredListing.CopyEndpoint(entry.Command.DedicatedFallback),
CreatedAtMonotonic = entry.CreatedAtMonotonic,
ExpiresAt = entry.WallExpiresAt, ExpiresAt = entry.WallExpiresAt,
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default, 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, ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
IsCancelled = entry.IsCancelled,
}; };
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now) private static int RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
{ {
foreach (string key in entries string[] expired = entries
.Where(item => item.Value <= now) .Where(item => item.Value <= now)
.Select(static item => item.Key) .Select(static item => item.Key)
.ToArray()) .ToArray();
foreach (string key in expired)
{ {
entries.Remove(key); entries.Remove(key);
} }
return expired.Length;
} }
private static void ValidateListing(ListingDefinition listing) private static void ValidateListing(ListingDefinition listing)
@@ -863,6 +1072,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| listing.CurrentPlayers < 0 || listing.CurrentPlayers < 0
|| listing.CurrentPlayers > listing.MaximumPlayers || listing.CurrentPlayers > listing.MaximumPlayers
|| !ContractValidation.IsMetadataValid(listing.Metadata) || !ContractValidation.IsMetadataValid(listing.Metadata)
|| listing.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback)
|| !listing.LeaseFingerprint.IsValid || !listing.LeaseFingerprint.IsValid
|| !listing.HostPresenceFingerprint.IsValid || !listing.HostPresenceFingerprint.IsValid
|| !IsDerivationSaltValid(listing.CapabilityDerivationSalt)) || !IsDerivationSaltValid(listing.CapabilityDerivationSalt))
@@ -903,6 +1114,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| !command.HostCapabilityFingerprint.IsValid || !command.HostCapabilityFingerprint.IsValid
|| !command.ClientCapabilityFingerprint.IsValid || !command.ClientCapabilityFingerprint.IsValid
|| !command.ConnectionTicketFingerprint.IsValid || !command.ConnectionTicketFingerprint.IsValid
|| command.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback)
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt) || !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|| command.ScopeAttemptLimit <= 0) || command.ScopeAttemptLimit <= 0)
{ {
@@ -962,10 +1175,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private sealed class AttemptEntry( private sealed class AttemptEntry(
CreateJoinAttemptCommand command, CreateJoinAttemptCommand command,
TimeSpan createdAtMonotonic,
TimeSpan deadline, TimeSpan deadline,
DateTimeOffset wallExpiresAt) DateTimeOffset wallExpiresAt)
{ {
public CreateJoinAttemptCommand Command { get; } = command; public CreateJoinAttemptCommand Command { get; } = command;
public TimeSpan CreatedAtMonotonic { get; } = createdAtMonotonic;
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 SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
@@ -977,6 +1192,20 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
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; } public bool ConnectionTicketConsumed { get; set; }
public bool IsCancelled { get; set; }
}
private sealed class OutcomeReportEntry(
SessionListingId listingId,
string clientSubject,
SecretFingerprint clientCapabilityFingerprint,
TimeSpan deadline)
{
public SessionListingId ListingId { get; } = listingId;
public string ClientSubject { get; } = clientSubject;
public SecretFingerprint ClientCapabilityFingerprint { get; } = clientCapabilityFingerprint;
public TimeSpan Deadline { get; } = deadline;
public StoredConnectionOutcome? Outcome { get; set; }
} }
private sealed record IdempotencyEntry( private sealed record IdempotencyEntry(
@@ -984,3 +1213,16 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
object ResourceId, object ResourceId,
TimeSpan Deadline); TimeSpan Deadline);
} }
internal sealed record EphemeralStoreSnapshot(
int ActiveListings,
int FreshPresenceBindings,
int ActiveJoinAttempts,
int RetainedOutcomeReports,
int ReplayMarkers,
int PrincipalRevocations,
int IdempotencyEntries,
long MaintenanceSweeps,
long ExpiryChurn,
bool IsAvailable,
bool IsDraining);
@@ -13,6 +13,8 @@ internal static class StoreResultMapping
StoreResultCode.Conflict => RendezvousErrorCode.Conflict, StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded, StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected, StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
StoreResultCode.StaleHost => RendezvousErrorCode.StaleHost,
StoreResultCode.IncompatibleProtocol => RendezvousErrorCode.IncompatibleProtocol,
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable => StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
RendezvousErrorCode.ServiceUnavailable, RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError, _ => 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,439 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Observability;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Transport;
internal interface INatIntroductionSink
{
void Introduce(NatIntroductionPlan plan);
}
internal sealed record NatIntroductionPlan(
IPEndPoint HostLocal,
IPEndPoint HostPublic,
IPEndPoint ClientLocal,
IPEndPoint ClientPublic,
string IntroductionToken)
{
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
}
internal enum NatMediationResult
{
Dropped = 0,
HostPresenceAccepted = 1,
HostPresenceRejected = 2,
WaitingForPeer = 3,
Introduced = 4,
Duplicate = 5,
Rejected = 6,
}
internal sealed class NatMediationProcessor(
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
JoinAttemptService joinAttempts,
AbuseProtectionService? abuseProtection = null,
RendezvousTelemetry? telemetry = null,
IMonotonicClock? monotonicClock = null)
{
public NatMediationResult ProcessDatagram(
ReadOnlySpan<byte> encoded,
IPEndPoint observedPublicEndpoint,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken = default)
{
if (!TryAcceptIngress(observedPublicEndpoint, "frozen"))
{
return NatMediationResult.Dropped;
}
return ProcessDatagramAfterIngress(
encoded,
observedPublicEndpoint,
introductionSink,
cancellationToken);
}
internal bool TryAcceptIngress(IPEndPoint observedPublicEndpoint, string operation) =>
abuseProtection is null
|| abuseProtection.TryAcceptUdpIngress(observedPublicEndpoint.Address, operation);
internal NatMediationResult ProcessDatagramAfterIngress(
ReadOnlySpan<byte> encoded,
IPEndPoint observedPublicEndpoint,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken = default)
{
long started = Stopwatch.GetTimestamp();
using Activity? activity = telemetry?.StartActivity("UDP frozen", ActivityKind.Server);
NatMediationResult result = ProcessDatagramCore(
encoded,
observedPublicEndpoint,
introductionSink,
cancellationToken);
telemetry?.RecordUdp(
"frozen",
result.ToString(),
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
return result;
}
private NatMediationResult ProcessDatagramCore(
ReadOnlySpan<byte> encoded,
IPEndPoint observedPublicEndpoint,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken)
{
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 = ProcessRequestCore(
claimedLocalEndpoint,
observedPublicEndpoint,
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
introductionSink,
cancellationToken);
if (role != NatPunchPeerRole.HostPresence
|| result != NatMediationResult.HostPresenceRejected
|| observedIpv6)
{
return result;
}
return ProcessRequestCore(
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 (!TryAcceptIngress(observedPublicEndpoint, "litenet-or-invalid"))
{
return NatMediationResult.Dropped;
}
return ProcessRequestAfterIngress(
claimedLocalEndpoint,
observedPublicEndpoint,
token,
introductionSink,
cancellationToken);
}
internal NatMediationResult ProcessRequestAfterIngress(
IPEndPoint claimedLocalEndpoint,
IPEndPoint observedPublicEndpoint,
string token,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken = default)
{
long started = Stopwatch.GetTimestamp();
using Activity? activity = telemetry?.StartActivity("UDP litenet", ActivityKind.Server);
NatMediationResult result = ProcessRequestCore(
claimedLocalEndpoint,
observedPublicEndpoint,
token,
introductionSink,
cancellationToken);
telemetry?.RecordUdp(
"litenet",
result.ToString(),
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
return result;
}
private NatMediationResult ProcessRequestCore(
IPEndPoint claimedLocalEndpoint,
IPEndPoint observedPublicEndpoint,
string token,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken)
{
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;
}
string operation = request.Role.ToString();
if (abuseProtection is not null
&& !abuseProtection.TryAcceptUdpIdentity(
operation,
observedPublicEndpoint.Address,
request.Capability,
request.MediationHandle.ToString()))
{
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));
if (telemetry is not null && monotonicClock is not null)
{
telemetry.RecordPairingLatency(Math.Max(
0,
(monotonicClock.Elapsed - consumed.Value.Attempt.CreatedAtMonotonic)
.TotalMilliseconds));
}
return NatMediationResult.Introduced;
}
catch (Exception exception) when (exception is SocketException
or InvalidOperationException
or ArgumentException)
{
return NatMediationResult.Rejected;
}
}
private static NatIntroductionPlan CreatePlan(
IntroductionEndpoints endpoints,
ConnectionTicketGrant ticket)
{
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
bool sameNat = hostPublic.Address.Equals(clientPublic.Address);
IPEndPoint hostLocal = sameNat && endpoints.Host.LocalEndpoint is { } hostCandidate
? ToIpEndpoint(hostCandidate)
: hostPublic;
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
? ToIpEndpoint(clientCandidate)
: clientPublic;
return new(
hostLocal,
hostPublic,
clientLocal,
clientPublic,
ticket.Ticket);
}
private static bool TryCreateObservedEndpoint(
IPEndPoint source,
out ObservedEndpoint endpoint)
{
endpoint = default;
if (source.Port is < 1 or > 65_535)
{
return false;
}
IPAddress address = source.Address.IsIPv4MappedToIPv6
? source.Address.MapToIPv4()
: source.Address;
if (address.Equals(IPAddress.Any)
|| address.Equals(IPAddress.IPv6Any)
|| address.IsIPv6Multicast
|| IsIpv4MulticastOrBroadcast(address)
|| (address.AddressFamily == AddressFamily.InterNetworkV6
&& !IsGlobalIpv6(address)))
{
return false;
}
AddressFamilyKind family = address.AddressFamily switch
{
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
_ => default,
};
if (family == default)
{
return false;
}
endpoint = new(family, address.ToString(), source.Port);
return true;
}
private static bool TryCreatePrivateCandidate(
IPEndPoint source,
AddressFamilyKind publicFamily,
out ObservedEndpoint endpoint)
{
endpoint = default;
if (source.Port is < 1 or > 65_535)
{
return false;
}
IPAddress address = source.Address.IsIPv4MappedToIPv6
? source.Address.MapToIPv4()
: source.Address;
AddressFamilyKind family = address.AddressFamily switch
{
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
_ => default,
};
if (family != publicFamily || !IsPrivateUnicast(address))
{
return false;
}
endpoint = new(family, address.ToString(), source.Port);
return true;
}
private static bool IsPrivateUnicast(IPAddress address)
{
byte[] bytes = address.GetAddressBytes();
return address.AddressFamily switch
{
AddressFamily.InterNetwork => bytes[0] == 10
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 192 && bytes[1] == 168),
AddressFamily.InterNetworkV6 => (bytes[0] & 0xfe) == 0xfc,
_ => false,
};
}
private static bool IsGlobalIpv6(IPAddress address) =>
!address.Equals(IPAddress.IPv6Loopback)
&& !address.Equals(IPAddress.IPv6Any)
&& !address.IsIPv6LinkLocal
&& !address.IsIPv6Multicast
&& !address.IsIPv6SiteLocal
&& !IsPrivateUnicast(address)
&& !IsDocumentationIpv6(address);
private static bool IsIpv4MulticastOrBroadcast(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetwork)
{
return false;
}
byte[] bytes = address.GetAddressBytes();
return bytes[0] >= 224 || bytes.All(static value => value == byte.MaxValue);
}
private static bool IsDocumentationIpv6(IPAddress address)
{
byte[] bytes = address.GetAddressBytes();
return bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8;
}
private static IPEndPoint ToIpEndpoint(ObservedEndpoint endpoint) =>
new(IPAddress.Parse(endpoint.Address), endpoint.Port);
}
@@ -18,9 +18,17 @@ public sealed class UdpMediatorOptions
[Required] [Required]
public string ListenAddress { get; set; } = "0.0.0.0"; public string ListenAddress { get; set; } = "0.0.0.0";
public string? Ipv6ListenAddress { get; set; }
/// <summary> /// <summary>
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests. /// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
/// </summary> /// </summary>
[Range(0, 65_535)] [Range(0, 65_535)]
public int Port { get; set; } = 9050; public int Port { get; set; } = 9050;
[Range(1, 4_096)]
public int MaxDatagramsPerPoll { get; set; } = 256;
[Range(1, 100)]
public int PollIntervalMilliseconds { get; set; } = 2;
} }
@@ -1,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,72 @@ 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.IntroductionToken);
}
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
{
private INatIntroductionSink? _sink;
public void Attach(INatIntroductionSink sink) => _sink = sink;
public override void ProcessInboundPacket(
ref IPEndPoint endPoint,
ref byte[] data,
ref int length)
{
bool isFrozenEnvelope = length >= 2
&& data[0] == RendezvousUdpCodec.MagicFirst
&& data[1] == RendezvousUdpCodec.MagicSecond;
if (!processor.TryAcceptIngress(
endPoint,
isFrozenEnvelope ? "frozen" : "litenet-or-invalid"))
{
Drop(ref length);
return;
}
INatIntroductionSink? sink = _sink;
if (isFrozenEnvelope)
{
if (sink is not null)
{
_ = processor.ProcessDatagramAfterIngress(
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.ProcessRequestAfterIngress(
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;
}
} }
@@ -1,5 +1,8 @@
{ {
"Rendezvous": { "Rendezvous": {
"AbuseProtection": {
"OperatorAllowedAddresses": ["127.0.0.1", "::1"]
},
"Provisioning": { "Provisioning": {
"Issuer": "final-factory-rendezvous-development", "Issuer": "final-factory-rendezvous-development",
"Audience": "final-factory-rendezvous", "Audience": "final-factory-rendezvous",
@@ -14,6 +17,14 @@
"NotBefore": "2025-01-01T00:00:00Z", "NotBefore": "2025-01-01T00:00:00Z",
"SignUntil": "2035-01-01T00:00:00Z", "SignUntil": "2035-01-01T00:00:00Z",
"VerifyUntil": "2035-01-02T00:00:00Z" "VerifyUntil": "2035-01-02T00:00:00Z"
},
{
"KeyId": "development-operator-1",
"SecretReference": "development:ephemeral/rendezvous-operator-signing",
"CredentialKinds": ["Operator"],
"NotBefore": "2025-01-01T00:00:00Z",
"SignUntil": "2035-01-01T00:00:00Z",
"VerifyUntil": "2035-01-02T00:00:00Z"
} }
], ],
"Games": [ "Games": [
@@ -2,7 +2,50 @@
"Rendezvous": { "Rendezvous": {
"Udp": { "Udp": {
"ListenAddress": "0.0.0.0", "ListenAddress": "0.0.0.0",
"Port": 9050 "Port": 9050,
"MaxDatagramsPerPoll": 256,
"PollIntervalMilliseconds": 2
},
"Audit": {
"MaxEntries": 10000,
"RetentionDays": 30
},
"AbuseProtection": {
"WindowSeconds": 1,
"MaxTrackedKeys": 100000,
"CriticalTrackedKeyReserve": 2048,
"UdpTrackedKeyLimit": 70000,
"TrustedProxyAddresses": [],
"OperatorAllowedAddresses": [],
"HealthGlobalRequestsPerWindow": 1000,
"HealthGlobalConcurrency": 32,
"HealthIpPrefixRequestsPerWindow": 120,
"HealthIpPrefixConcurrency": 8,
"OperatorGlobalRequestsPerWindow": 1000,
"OperatorGlobalConcurrency": 32,
"OperatorIpPrefixRequestsPerWindow": 120,
"OperatorIpPrefixConcurrency": 8,
"HttpGlobalRequestsPerWindow": 20000,
"HttpOptionalRequestsPerWindow": 18000,
"HttpIpPrefixRequestsPerWindow": 500,
"HttpOptionalIpPrefixRequestsPerWindow": 450,
"HttpOperationRequestsPerWindow": 5000,
"HttpTenantRequestsPerWindow": 2000,
"HttpPrincipalRequestsPerWindow": 500,
"HttpResourceRequestsPerWindow": 200,
"HttpGlobalConcurrency": 1024,
"HttpOptionalConcurrency": 768,
"HttpIpPrefixConcurrency": 64,
"HttpOptionalIpPrefixConcurrency": 48,
"HttpOperationConcurrency": 256,
"HttpTenantConcurrency": 256,
"HttpPrincipalConcurrency": 32,
"HttpResourceConcurrency": 16,
"UdpGlobalDatagramsPerWindow": 100000,
"UdpIpPrefixDatagramsPerWindow": 2000,
"UdpOperationDatagramsPerWindow": 50000,
"UdpCapabilityDatagramsPerWindow": 120,
"UdpResourceDatagramsPerWindow": 240
} }
}, },
"Logging": { "Logging": {
@@ -0,0 +1,130 @@
using System.Security.Cryptography;
using System.Text;
using LiteNetLib;
using LiteNetLib.Utils;
namespace FinalFactory.Rendezvous.TestClient;
internal sealed class DirectEchoProtocol : IDisposable
{
private const string PingPrefix = "rv1-ping:";
private const string EchoPrefix = "rv1-echo:";
private const string AckPrefix = "rv1-ack:";
private const string DonePrefix = "rv1-done:";
private readonly EventBasedNetListener _events;
private readonly bool _host;
private readonly Dictionary<NetPeer, string> _hostNonces = [];
private readonly TaskCompletionSource<bool> _completed = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private string? _nonce;
private bool _disposed;
internal DirectEchoProtocol(EventBasedNetListener events, bool host)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_host = host;
_events.NetworkReceiveEvent += OnReceive;
_events.PeerDisconnectedEvent += OnPeerDisconnected;
}
internal Task Completion => _completed.Task;
internal int PendingHostExchangeCount => _hostNonces.Count;
internal event Action<NetPeer>? ExchangeCompleted;
internal void BeginJoin(NetPeer peer)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_host || _nonce is not null)
{
throw new InvalidOperationException("The direct echo exchange is already active.");
}
_nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
Send(peer, PingPrefix + _nonce);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_events.NetworkReceiveEvent -= OnReceive;
_events.PeerDisconnectedEvent -= OnPeerDisconnected;
_hostNonces.Clear();
_disposed = true;
}
private void OnReceive(
NetPeer peer,
NetPacketReader reader,
byte channel,
DeliveryMethod deliveryMethod)
{
try
{
ReadOnlySpan<byte> payload = reader.GetRemainingBytesSpan();
if (payload.Length is < 9 or > 64)
{
return;
}
string message = Encoding.ASCII.GetString(payload);
if (_host && TryNonce(message, PingPrefix, out string? pingNonce))
{
_hostNonces[peer] = pingNonce!;
Send(peer, EchoPrefix + pingNonce);
}
else if (_host
&& _hostNonces.TryGetValue(peer, out string? hostNonce)
&& string.Equals(message, AckPrefix + hostNonce, StringComparison.Ordinal))
{
_hostNonces.Remove(peer);
Send(peer, DonePrefix + hostNonce);
ExchangeCompleted?.Invoke(peer);
_completed.TrySetResult(true);
}
else if (!_host
&& _nonce is not null
&& string.Equals(message, EchoPrefix + _nonce, StringComparison.Ordinal))
{
Send(peer, AckPrefix + _nonce);
}
else if (!_host
&& _nonce is not null
&& string.Equals(message, DonePrefix + _nonce, StringComparison.Ordinal))
{
ExchangeCompleted?.Invoke(peer);
_completed.TrySetResult(true);
}
}
finally
{
reader.Recycle();
}
}
private static bool TryNonce(string message, string prefix, out string? nonce)
{
nonce = null;
if (!message.StartsWith(prefix, StringComparison.Ordinal)
|| message.Length != prefix.Length + 32)
{
return false;
}
string candidate = message[prefix.Length..];
if (!candidate.All(static character => character is >= '0' and <= '9'
or >= 'a' and <= 'f'))
{
return false;
}
nonce = candidate;
return true;
}
private static void Send(NetPeer peer, string message) => peer.Send(
Encoding.ASCII.GetBytes(message),
DeliveryMethod.ReliableOrdered);
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) =>
_hostNonces.Remove(peer);
}
@@ -0,0 +1,36 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.TestClient;
internal sealed class HostServiceFailureBudget
{
private const int MaximumConsecutiveTransientFailures = 3;
private int _consecutiveTransientFailures;
internal bool ShouldStop(
RendezvousErrorCode error,
DateTimeOffset leaseExpiresAt,
DateTimeOffset now)
{
if (error == RendezvousErrorCode.None)
{
Reset();
return false;
}
if (!IsTransient(error))
{
return true;
}
_consecutiveTransientFailures++;
return _consecutiveTransientFailures >= MaximumConsecutiveTransientFailures
|| now >= leaseExpiresAt;
}
internal void Reset() => _consecutiveTransientFailures = 0;
private static bool IsTransient(RendezvousErrorCode error) => error is
RendezvousErrorCode.RateLimited
or RendezvousErrorCode.ServiceUnavailable
or RendezvousErrorCode.InternalError;
}
@@ -1,16 +1,29 @@
namespace FinalFactory.Rendezvous.TestClient; namespace FinalFactory.Rendezvous.TestClient;
/// <summary>
/// Bootstrap entry point for the public-SDK-only diagnostic client.
/// </summary>
public static class Program public static class Program
{ {
/// <summary> public static async Task<int> Main(string[] args)
/// Runs the bootstrap diagnostic.
/// </summary>
public static int Main()
{ {
Console.WriteLine("Rendezvous TestClient bootstrap is ready."); using CancellationTokenSource shutdown = new();
return 0; ConsoleCancelEventHandler cancelHandler = (_, eventArgs) =>
{
eventArgs.Cancel = true;
shutdown.Cancel();
};
Console.CancelKeyPress += cancelHandler;
try
{
TestClientApplication application = new(new RendezvousCommandRunner());
return await application.RunAsync(
args,
Console.In,
Console.Out,
Console.Error,
shutdown.Token).ConfigureAwait(false);
}
finally
{
Console.CancelKeyPress -= cancelHandler;
}
} }
} }
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
@@ -0,0 +1,56 @@
# FinalFactory.Rendezvous.TestClient
This is a diagnostic executable for exercising Rendezvous through the same public
Client and Contracts API available to a game. It is not a production game client,
server browser, dedicated server, relay, account system, or gameplay host.
The executable has three explicit modes:
- `host` publishes a session, maintains presence and its lease, accepts an
authenticated direct peer, and answers a bounded ping/echo/ack/completion exchange;
- `browse` prints compatible public listings; and
- `join` selects or accepts a listing, drives traversal on its caller-owned
LiteNetLib socket, proves direct traffic, reports the typed outcome, and exits.
Run `dotnet run --project src/FinalFactory.Rendezvous.TestClient -- --help` for
the complete option reference. A typical script-mode invocation is:
```bash
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<credential from the deployment boundary>'
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
--game space-game --environment development --region local --protocol 1 \
--script --json --exit-after-echo
```
Publisher credentials are accepted only through a named environment variable.
There is deliberately no command-line credential option because process command
lines are routinely exposed to other local tools and diagnostics. Output uses an
allowlisted event model and never includes lease tokens, punch capabilities,
connection tickets, raw metadata, signing material, or reusable credentials.
Script mode never prompts. Join mode selects the first compatible listing unless
`--listing UUID` fixes the choice. `--json` emits one JSON object per line with
`version: 1`; event names and the process exit codes below are stable automation
contracts. A script-mode host without `--run-seconds` uses `--timeout-seconds` as
its total runtime bound. New optional event properties may be added without changing
the version. JSON help and usage failures are versioned events as well; informational
events use stdout and failures use stderr.
| Exit | Meaning |
|---:|---|
| `0` | Requested diagnostic flow completed successfully |
| `2` | Invalid command or options |
| `3` | Missing or invalid local configuration |
| `10` | HTTP, registration, browser, lease, or socket failure |
| `11` | No compatible session was available or selected |
| `12` | Authorization or traversal reached a typed terminal failure |
| `13` | A requested direct ping/echo proof did not complete |
| `130` | Caller cancellation or Ctrl+C |
The client prints the selected direct endpoint category (`loopback`, `private`, or
`public`) but never the raw endpoint. A traversal failure reports whether an
authoritative dedicated fallback is available; the diagnostic does not connect to
that fallback automatically. A host may publish a policy-authorized endpoint with
`--fallback IP:PORT`. See the repository integration guide for process
orchestration and topology limitations.
@@ -0,0 +1,774 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.TestClient;
internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
{
private static readonly TimeSpan PollDelay = TimeSpan.FromMilliseconds(5);
private static readonly TimeSpan HostRefreshInterval = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan DirectTrafficFlushGrace = TimeSpan.FromMilliseconds(500);
public Task<TestClientExitCode> RunAsync(
TestClientOptions options,
TestClientOutput output,
TextReader input,
CancellationToken cancellationToken) => options.Mode switch
{
TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
_ => Task.FromResult(TestClientExitCode.Usage),
};
private static async Task<TestClientExitCode> RunHostAsync(
TestClientOptions options,
TestClientOutput output,
CancellationToken cancellationToken)
{
string? publisherCredential = Environment.GetEnvironmentVariable(
options.PublisherCredentialEnvironmentVariable);
if (!ContractValidation.IsOpaqueHttpCredentialValid(publisherCredential))
{
output.WriteError(
"host.configuration",
"failed",
"The publisher credential environment variable is missing or invalid.",
phase: "configuration");
return TestClientExitCode.Configuration;
}
string credential = publisherCredential!;
using HttpClient http = CreateHttpClient(options);
RendezvousPublisherClient publisher = new(http, ClientOptions(options));
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
RendezvousJoinClient joins = new(http, ClientOptions(options));
RendezvousNetListener events = new();
NetManager manager = events.CreateManager();
if (!manager.Start(options.LocalPort))
{
output.WriteError("host.socket", "failed", "The gameplay UDP socket could not start.", phase: "presence");
return TestClientExitCode.ServiceFailure;
}
PublishedSession? session = null;
using CancellationTokenSource hostOperations = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task<RendezvousClientResult<int>>? refresh = null;
Task<RendezvousClientResult<RenewLeaseResponse>>? renewal = null;
Task<RendezvousClientResult<GetSessionResponse>>? readiness = null;
DirectEchoProtocol? echo = null;
RendezvousHostCoordinator? coordinator = null;
TestClientExitCode hostResult = TestClientExitCode.ServiceFailure;
bool cleanupFailed = false;
try
{
output.Write("host.registration", "started", phase: "registration");
RendezvousClientResult<PublishedSession> registration;
using (CancellationTokenSource registrationTimeout = CreateOperationTimeout(options, cancellationToken))
{
try
{
registration = await publisher.RegisterAsync(
new RegisterSessionRequest
{
IdempotencyKey = Guid.NewGuid().ToString("N"),
GameId = options.GameId,
EnvironmentId = options.EnvironmentId,
RegionId = options.RegionId,
ProtocolVersion = options.ProtocolVersion,
BuildVersion = options.BuildVersion,
DisplayName = options.DisplayName,
Visibility = ListingVisibility.Public,
Capacity = new SessionCapacity { CurrentPlayers = 1, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(options.Metadata, StringComparer.Ordinal),
DedicatedFallback = options.DedicatedFallback,
},
credential,
registrationTimeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
output.WriteError(
"host.registration",
"timed-out",
"Host registration exceeded the bounded startup stage.",
phase: "registration");
return TestClientExitCode.ServiceFailure;
}
}
if (!registration.IsSuccess || registration.Value is null)
{
WriteServiceFailure(output, "host.registration", "registration", registration);
return TestClientExitCode.ServiceFailure;
}
session = registration.Value;
output.Write(
"host.registered",
"registered",
phase: "registration",
listingId: session.ListingId.ToString(),
displayName: options.DisplayName);
echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
echo.ExchangeCompleted += _ => output.Write(
"host.direct-traffic",
"verified",
phase: "direct-traffic",
endpointType: "peer-to-peer");
coordinator = new RendezvousHostCoordinator(
manager,
events,
options.Mediator,
session,
joins,
CoordinatorOptions(options));
coordinator.AttemptCompleted += (_, completion) =>
{
output.Write(
"host.attempt.completed",
completion.Outcome.IsSuccess ? "connected" : "failed",
phase: completion.Outcome.Phase.ToString(),
outcome: completion.Outcome.Kind.ToString(),
elapsedMilliseconds: ToMilliseconds(completion.Outcome.Elapsed));
if (completion.Outcome.IsSuccess)
{
output.Write(
"host.direct-connect",
"connected",
phase: "direct-connection",
endpointType: "peer-to-peer");
}
};
Stopwatch running = Stopwatch.StartNew();
TimeSpan nextRefresh = TimeSpan.Zero;
TimeSpan nextRenewal = TimeSpan.FromSeconds(session.LeaseRenewAfterSeconds);
TimeSpan nextReadinessProbe = TimeSpan.Zero;
bool directTrafficReported = false;
TimeSpan? directTrafficCompletedAt = null;
bool ready = false;
bool terminalFailure = false;
int previousPendingAttempts = 0;
HostServiceFailureBudget refreshFailures = new();
HostServiceFailureBudget renewalFailures = new();
using PeriodicTimer pollTimer = new(PollDelay);
while (!cancellationToken.IsCancellationRequested)
{
coordinator.Poll();
if (coordinator.State != RendezvousHostState.Active)
{
output.WriteError(
"host.lifecycle",
"failed",
"The host coordinator stopped before shutdown was requested.",
phase: "lifecycle",
outcome: coordinator.State.ToString());
terminalFailure = true;
break;
}
if (coordinator.PendingAttemptCount > previousPendingAttempts)
{
output.Write(
"host.punch",
"started",
phase: "nat-traversal",
count: coordinator.PendingAttemptCount);
}
previousPendingAttempts = coordinator.PendingAttemptCount;
if (readiness is { IsCompleted: true })
{
RendezvousClientResult<GetSessionResponse> result = await readiness.ConfigureAwait(false);
readiness = null;
if (result.IsSuccess)
{
ready = true;
output.Write(
"host.ready",
"ready",
phase: "presence",
listingId: session.ListingId.ToString());
}
else
{
nextReadinessProbe = running.Elapsed + TimeSpan.FromMilliseconds(50);
}
}
if (!ready && readiness is null && running.Elapsed >= nextReadinessProbe)
{
readiness = browser.GetAsync(
session.ListingId,
options.GameId,
options.EnvironmentId,
options.ProtocolVersion,
hostOperations.Token);
}
if (refresh is { IsCompleted: true })
{
RendezvousClientResult<int> result = await refresh.ConfigureAwait(false);
refresh = null;
nextRefresh = running.Elapsed + (result.IsSuccess
? HostRefreshInterval
: TimeSpan.FromSeconds(1));
if (!result.IsSuccess)
{
WriteServiceFailure(output, "host.authorization", "authorization", result);
if (refreshFailures.ShouldStop(result.Error, session.ExpiresAt, DateTimeOffset.UtcNow))
{
terminalFailure = true;
break;
}
}
else
{
refreshFailures.Reset();
}
}
if (refresh is null && running.Elapsed >= nextRefresh)
{
refresh = coordinator.RefreshJoinAttemptsAsync(hostOperations.Token);
}
if (renewal is { IsCompleted: true })
{
RendezvousClientResult<RenewLeaseResponse> result = await renewal.ConfigureAwait(false);
renewal = null;
nextRenewal = running.Elapsed + (result.IsSuccess && result.Value is not null
? TimeSpan.FromSeconds(result.Value.RenewAfterSeconds)
: TimeSpan.FromSeconds(1));
output.Write(
"host.lease",
result.IsSuccess ? "renewed" : "failed",
phase: "lease",
message: result.IsSuccess ? null : SafeServiceMessage(result));
if (!result.IsSuccess
&& renewalFailures.ShouldStop(result.Error, session.ExpiresAt, DateTimeOffset.UtcNow))
{
terminalFailure = true;
break;
}
if (result.IsSuccess)
{
renewalFailures.Reset();
}
}
if (renewal is null && running.Elapsed >= nextRenewal)
{
renewal = publisher.RenewAsync(session, credential, hostOperations.Token);
}
if (echo.Completion.IsCompleted && !directTrafficReported)
{
directTrafficReported = true;
directTrafficCompletedAt = running.Elapsed;
}
if (options.ExitAfterEcho
&& directTrafficCompletedAt.HasValue
&& running.Elapsed - directTrafficCompletedAt.Value >= DirectTrafficFlushGrace)
{
break;
}
if (options.RunDuration.HasValue && running.Elapsed >= options.RunDuration.Value)
{
break;
}
if (!await pollTimer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
break;
}
}
if (cancellationToken.IsCancellationRequested)
{
hostResult = TestClientExitCode.Cancelled;
}
else if (terminalFailure)
{
hostResult = TestClientExitCode.ServiceFailure;
}
else if (options.ExitAfterEcho && !directTrafficReported)
{
output.WriteError(
"host.direct-traffic",
"timed-out",
"No authenticated ping/echo/ack exchange completed within the host runtime.",
phase: "direct-traffic");
hostResult = TestClientExitCode.DirectTrafficFailed;
}
else
{
hostResult = TestClientExitCode.Success;
}
}
finally
{
hostOperations.Cancel();
await ObserveCancellationAsync(refresh).ConfigureAwait(false);
await ObserveCancellationAsync(renewal).ConfigureAwait(false);
await ObserveCancellationAsync(readiness).ConfigureAwait(false);
coordinator?.Dispose();
echo?.Dispose();
if (session is not null)
{
using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5));
try
{
RendezvousClientResult<bool> deregistered = await publisher.DeregisterAsync(
session,
credential,
cleanup.Token).ConfigureAwait(false);
output.Write(
"host.deregistered",
deregistered.IsSuccess ? "complete" : "failed",
phase: "lifecycle",
listingId: session.ListingId.ToString());
if (!deregistered.IsSuccess)
{
cleanupFailed = true;
}
}
catch (OperationCanceledException)
{
output.WriteError(
"host.deregistered",
"timed-out",
"Deregistration did not complete within the cleanup budget.",
phase: "lifecycle");
cleanupFailed = true;
}
}
manager.Stop();
}
return cleanupFailed && !cancellationToken.IsCancellationRequested
? TestClientExitCode.ServiceFailure
: hostResult;
}
private static async Task<TestClientExitCode> RunBrowseAsync(
TestClientOptions options,
TestClientOutput output,
CancellationToken cancellationToken)
{
using CancellationTokenSource operation = CreateOperationTimeout(options, cancellationToken);
using HttpClient http = CreateHttpClient(options);
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
output.Write("browse.sessions", "started", phase: "directory");
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(
BrowseRequest(options),
maximumPages: 10,
cancellationToken: operation.Token).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
WriteServiceFailure(output, "browse.sessions", "directory", result);
return TestClientExitCode.ServiceFailure;
}
WriteListings(output, result.Value);
return result.Value.Count == 0
? TestClientExitCode.NoCompatibleSession
: TestClientExitCode.Success;
}
private static async Task<TestClientExitCode> RunJoinAsync(
TestClientOptions options,
TestClientOutput output,
TextReader input,
CancellationToken cancellationToken)
{
using CancellationTokenSource operation = CreateOperationTimeout(options, cancellationToken);
using HttpClient http = CreateHttpClient(options);
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
RendezvousJoinClient joins = new(http, ClientOptions(options));
SessionSelection selection = await SelectListingAsync(
options,
output,
input,
browser,
operation.Token).ConfigureAwait(false);
if (selection.Listing is null)
{
return selection.ExitCode;
}
SessionListing listing = selection.Listing;
RendezvousNetListener events = new();
NetManager manager = events.CreateManager();
if (!manager.Start(options.LocalPort))
{
output.WriteError("join.socket", "failed", "The gameplay UDP socket could not start.", phase: "mediation");
return TestClientExitCode.ServiceFailure;
}
RendezvousClientCoordinator? coordinator = null;
bool directConnected = false;
try
{
output.Write(
"join.authorization",
"started",
phase: "authorization",
listingId: listing.ListingId.ToString());
RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync(
new CreateJoinAttemptRequest
{
IdempotencyKey = Guid.NewGuid().ToString("N"),
GameId = options.GameId,
EnvironmentId = options.EnvironmentId,
ListingId = listing.ListingId,
ProtocolVersion = options.ProtocolVersion,
},
listing.DedicatedFallback,
operation.Token).ConfigureAwait(false);
if (start.Outcome is { } serviceOutcome)
{
cancellationToken.ThrowIfCancellationRequested();
WriteOutcome(output, "join.authorization", serviceOutcome);
WriteFallback(output, serviceOutcome);
return TestClientExitCode.TraversalFailed;
}
CreateJoinAttemptResponse attempt = start.Attempt
?? throw new InvalidOperationException("The typed start result had no attempt or outcome.");
using DirectEchoProtocol echo = new(events.GameplayEvents, host: false);
coordinator = new RendezvousClientCoordinator(
manager,
events,
options.Mediator,
attempt,
CoordinatorOptions(options));
output.Write("join.punch", "started", phase: "nat-traversal");
using (CancellationTokenSource traversal = CreateOperationTimeout(options, cancellationToken))
using (PeriodicTimer traversalPoll = new(PollDelay))
{
RendezvousConnectionState previousState = coordinator.State;
while (!coordinator.IsCompleted)
{
traversal.Token.ThrowIfCancellationRequested();
coordinator.Poll();
if (coordinator.State != previousState)
{
previousState = coordinator.State;
if (previousState == RendezvousConnectionState.Connecting)
{
output.Write(
"join.direct-connect",
"started",
phase: "direct-connection");
}
}
if (!coordinator.IsCompleted
&& !await traversalPoll.WaitForNextTickAsync(traversal.Token).ConfigureAwait(false))
{
break;
}
}
}
RendezvousConnectionOutcome outcome = coordinator.Outcome
?? throw new InvalidOperationException("The completed coordinator had no typed outcome.");
WriteOutcome(output, "join.traversal", outcome);
if (!outcome.IsSuccess || coordinator.ConnectedPeer is null)
{
WriteFallback(output, outcome);
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
return TestClientExitCode.TraversalFailed;
}
NetPeer peer = coordinator.ConnectedPeer;
directConnected = true;
string endpointType = EndpointType(peer.Address);
output.Write(
"join.connected",
"connected",
phase: "direct-connection",
endpointType: endpointType,
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
echo.BeginJoin(peer);
using (CancellationTokenSource traffic = CreateOperationTimeout(options, cancellationToken))
using (PeriodicTimer trafficPoll = new(PollDelay))
{
while (!echo.Completion.IsCompleted)
{
traffic.Token.ThrowIfCancellationRequested();
manager.PollEvents();
if (!echo.Completion.IsCompleted
&& !await trafficPoll.WaitForNextTickAsync(traffic.Token).ConfigureAwait(false))
{
break;
}
}
}
await echo.Completion.ConfigureAwait(false);
output.Write(
"join.direct-traffic",
"verified",
phase: "direct-traffic",
endpointType: endpointType);
peer.Disconnect();
manager.PollEvents();
return TestClientExitCode.Success;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
output.WriteError(
"join.timeout",
"timed-out",
"The bounded join operation timed out.",
phase: "lifecycle",
outcome: ConnectionOutcomeKind.TimedOut.ToString());
if (coordinator is not null && !coordinator.IsCompleted)
{
coordinator.Poll();
}
if (coordinator is not null && !coordinator.IsCompleted)
{
coordinator.Cancel();
coordinator.Poll();
if (coordinator.Outcome is { } timeoutOutcome)
{
WriteOutcome(output, "join.traversal", timeoutOutcome);
WriteFallback(output, timeoutOutcome, listing.DedicatedFallback);
await ReportOutcomeAsync(
coordinator,
joins,
output,
cancellationToken).ConfigureAwait(false);
}
}
return directConnected
? TestClientExitCode.DirectTrafficFailed
: TestClientExitCode.TraversalFailed;
}
finally
{
coordinator?.Dispose();
manager.Stop();
}
}
private static async Task<SessionSelection> SelectListingAsync(
TestClientOptions options,
TestClientOutput output,
TextReader input,
RendezvousSessionBrowserClient browser,
CancellationToken cancellationToken)
{
if (options.ListingId.HasValue)
{
RendezvousClientResult<GetSessionResponse> exact = await browser.GetAsync(
options.ListingId.Value,
options.GameId,
options.EnvironmentId,
options.ProtocolVersion,
cancellationToken).ConfigureAwait(false);
if (!exact.IsSuccess || exact.Value is null)
{
WriteServiceFailure(output, "join.selection", "directory", exact);
return new(null, TestClientExitCode.ServiceFailure);
}
return new(exact.Value.Session, TestClientExitCode.Success);
}
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(
BrowseRequest(options),
maximumPages: 10,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
WriteServiceFailure(output, "join.selection", "directory", result);
return new(null, TestClientExitCode.ServiceFailure);
}
if (result.Value.Count == 0)
{
output.Write("join.selection", "empty", phase: "directory", count: 0);
return new(null, TestClientExitCode.NoCompatibleSession);
}
WriteListings(output, result.Value);
if (options.Script)
{
return new(result.Value[0], TestClientExitCode.Success);
}
output.WritePrompt($"Select session [1-{result.Value.Count}]: ");
string? selection = await input.ReadLineAsync(cancellationToken).ConfigureAwait(false);
SessionListing? selected = int.TryParse(selection, out int index)
&& index >= 1
&& index <= result.Value.Count
? result.Value[index - 1]
: null;
return selected is null
? new(null, TestClientExitCode.NoCompatibleSession)
: new(selected, TestClientExitCode.Success);
}
private static void WriteListings(TestClientOutput output, IReadOnlyList<SessionListing> listings)
{
output.Write("browse.completed", "complete", phase: "directory", count: listings.Count);
foreach (SessionListing listing in listings)
{
output.Write(
"browse.session",
"available",
phase: "directory",
listingId: listing.ListingId.ToString(),
displayName: listing.DisplayName);
}
}
private static BrowseSessionsRequest BrowseRequest(TestClientOptions options) => new()
{
GameId = options.GameId,
EnvironmentId = options.EnvironmentId,
ProtocolVersion = options.ProtocolVersion,
RegionId = options.RegionId,
PageSize = options.PageSize,
ExcludeFull = true,
};
private static HttpClient CreateHttpClient(TestClientOptions options) => new()
{
BaseAddress = options.ServiceUri,
Timeout = Timeout.InfiniteTimeSpan,
};
private static RendezvousClientOptions ClientOptions(TestClientOptions options) => new()
{
RequestTimeout = TimeSpan.FromSeconds(Math.Min(30, options.OperationTimeout.TotalSeconds)),
};
private static RendezvousCoordinatorOptions CoordinatorOptions(TestClientOptions options)
{
TimeSpan phaseTimeout = TimeSpan.FromSeconds(
Math.Min(30, options.OperationTimeout.TotalSeconds * 0.45));
return new RendezvousCoordinatorOptions
{
PunchTimeout = phaseTimeout,
DirectConnectTimeout = phaseTimeout,
};
}
private static CancellationTokenSource CreateOperationTimeout(
TestClientOptions options,
CancellationToken cancellationToken)
{
CancellationTokenSource source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
source.CancelAfter(options.OperationTimeout);
return source;
}
private static async Task ReportOutcomeAsync(
RendezvousClientCoordinator coordinator,
RendezvousJoinClient joins,
TestClientOutput output,
CancellationToken callerCancellationToken)
{
using CancellationTokenSource telemetry = CancellationTokenSource.CreateLinkedTokenSource(
callerCancellationToken);
telemetry.CancelAfter(TimeSpan.FromSeconds(5));
try
{
RendezvousClientResult<ReportConnectionOutcomeResponse> report =
await coordinator.ReportOutcomeAsync(joins, telemetry.Token).ConfigureAwait(false);
output.Write(
"join.outcome-report",
report.IsSuccess ? "accepted" : "failed",
phase: "telemetry",
message: report.IsSuccess ? null : SafeServiceMessage(report));
}
catch (OperationCanceledException) when (!callerCancellationToken.IsCancellationRequested)
{
output.WriteError(
"join.outcome-report",
"cancelled",
"Outcome reporting was cancelled within the operation budget.",
phase: "telemetry");
}
}
private static void WriteOutcome(
TestClientOutput output,
string eventName,
RendezvousConnectionOutcome outcome) => output.Write(
eventName,
outcome.IsSuccess ? "connected" : "failed",
phase: outcome.Phase.ToString(),
outcome: outcome.Kind.ToString(),
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
private static void WriteFallback(
TestClientOutput output,
RendezvousConnectionOutcome outcome,
NetworkEndpoint? authoritativeFallback = null)
{
bool hasFallback = outcome.HasDedicatedFallback || authoritativeFallback is not null;
output.Write(
"join.fallback",
hasFallback ? "available" : "unavailable",
phase: "fallback",
outcome: outcome.Kind.ToString(),
endpointType: hasFallback ? "dedicated" : "none");
}
private static void WriteServiceFailure<T>(
TestClientOutput output,
string eventName,
string phase,
RendezvousClientResult<T> result) => output.WriteError(
eventName,
"failed",
SafeServiceMessage(result),
phase,
result.Error.ToString());
private static string SafeServiceMessage<T>(RendezvousClientResult<T> result) =>
$"Rendezvous returned {result.Error}.";
private static async Task ObserveCancellationAsync<T>(Task<T>? task)
{
if (task is null)
{
return;
}
try
{
await task.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
catch (ObjectDisposedException)
{
}
}
private static string EndpointType(IPAddress address)
{
if (IPAddress.IsLoopback(address))
{
return "loopback";
}
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
byte[] ipv6 = address.GetAddressBytes();
return address.IsIPv6LinkLocal || (ipv6[0] & 0xfe) == 0xfc
? "private"
: "public";
}
byte[] bytes = address.GetAddressBytes();
bool privateAddress = bytes[0] == 10
|| bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|| bytes[0] == 192 && bytes[1] == 168;
return privateAddress ? "private" : "public";
}
private static long ToMilliseconds(TimeSpan elapsed) =>
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
private sealed record SessionSelection(
SessionListing? Listing,
TestClientExitCode ExitCode);
}
@@ -0,0 +1,95 @@
namespace FinalFactory.Rendezvous.TestClient;
internal enum TestClientExitCode
{
Success = 0,
Usage = 2,
Configuration = 3,
ServiceFailure = 10,
NoCompatibleSession = 11,
TraversalFailed = 12,
DirectTrafficFailed = 13,
Cancelled = 130,
}
internal interface ITestClientCommandRunner
{
Task<TestClientExitCode> RunAsync(
TestClientOptions options,
TestClientOutput output,
TextReader input,
CancellationToken cancellationToken);
}
internal sealed class TestClientApplication(ITestClientCommandRunner runner)
{
private readonly ITestClientCommandRunner _runner = runner ?? throw new ArgumentNullException(nameof(runner));
internal async Task<int> RunAsync(
string[] args,
TextReader input,
TextWriter standardOutput,
TextWriter standardError,
CancellationToken cancellationToken)
{
bool jsonRequested = args.Contains("--json", StringComparer.Ordinal);
TestClientParseResult parsed = TestClientOptionParser.Parse(args);
TestClientOutput output = new(standardOutput, standardError, jsonRequested);
if (parsed.ShowHelp)
{
if (jsonRequested)
{
output.Write(
"cli.help",
"complete",
phase: "configuration",
message: "Run without --json to read the full command reference.");
}
else
{
await standardOutput.WriteLineAsync(TestClientOptionParser.Usage).ConfigureAwait(false);
}
return (int)TestClientExitCode.Success;
}
if (!parsed.Succeeded || parsed.Options is null)
{
if (jsonRequested)
{
output.WriteError(
"cli.usage",
"failed",
parsed.Error ?? "Invalid command line.",
phase: "configuration");
}
else
{
await standardError.WriteLineAsync(parsed.Error ?? "Invalid command line.").ConfigureAwait(false);
await standardError.WriteLineAsync("Use --help for documented options.").ConfigureAwait(false);
}
return (int)TestClientExitCode.Usage;
}
output = new TestClientOutput(standardOutput, standardError, parsed.Options.Json);
try
{
return (int)await _runner.RunAsync(
parsed.Options,
output,
input,
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
output.Write("lifecycle.cancelled", "cancelled", phase: "lifecycle");
return (int)TestClientExitCode.Cancelled;
}
catch (Exception exception)
{
output.WriteError(
"lifecycle.failed",
"failed",
$"Unexpected {exception.GetType().Name}; credentials remain redacted.");
return (int)TestClientExitCode.ServiceFailure;
}
}
}
@@ -0,0 +1,377 @@
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.TestClient;
internal enum TestClientMode
{
Host,
Browse,
Join,
}
internal sealed class TestClientOptions
{
internal TestClientMode Mode { get; init; }
internal Uri ServiceUri { get; init; } = new("http://127.0.0.1:5000/");
internal IPEndPoint Mediator { get; init; } = new(IPAddress.Loopback, 9050);
internal GameId GameId { get; init; } = new("space-game");
internal EnvironmentId EnvironmentId { get; init; } = new("development");
internal RegionId RegionId { get; init; } = new("local");
internal uint ProtocolVersion { get; init; } = 1;
internal string BuildVersion { get; init; } = "test-client";
internal string DisplayName { get; init; } = "Rendezvous diagnostic host";
internal string PublisherCredentialEnvironmentVariable { get; init; } =
"RENDEZVOUS_PUBLISHER_CREDENTIAL";
internal Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
internal NetworkEndpoint? DedicatedFallback { get; init; }
internal SessionListingId? ListingId { get; init; }
internal int LocalPort { get; init; }
internal int PageSize { get; init; } = 20;
internal TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(20);
internal TimeSpan? RunDuration { get; init; }
internal bool Script { get; init; }
internal bool Json { get; init; }
internal bool ExitAfterEcho { get; init; }
}
internal sealed class TestClientParseResult
{
private TestClientParseResult(TestClientOptions? options, string? error, bool showHelp)
{
Options = options;
Error = error;
ShowHelp = showHelp;
}
internal TestClientOptions? Options { get; }
internal string? Error { get; }
internal bool ShowHelp { get; }
internal bool Succeeded => Options is not null;
internal static TestClientParseResult Success(TestClientOptions options) => new(options, null, false);
internal static TestClientParseResult Failure(string error) => new(null, error, false);
internal static TestClientParseResult Help() => new(null, null, true);
}
internal static class TestClientOptionParser
{
internal const string Usage = """
Rendezvous diagnostic client
Usage:
rendezvous-test-client host [options]
rendezvous-test-client browse [options]
rendezvous-test-client join [options]
Common options:
--service URL HTTP(S) Rendezvous base URL
--mediator IP:PORT UDP mediator endpoint
--game ID Game scope (default: space-game)
--environment ID Environment scope (default: development)
--protocol NUMBER Exact gameplay protocol (default: 1)
--region ID Region filter/publication (default: local)
--timeout-seconds NUMBER Bounded startup/traversal stage, 1-300 (default: 20)
--port NUMBER Caller-owned gameplay UDP port; 0 chooses one
--page-size NUMBER Bounded browser page size, 1-100 (default: 20)
--script Never prompt; select the first compatible listing
--json Emit one versioned JSON event per line
--help Show this help
Host options:
--publisher-credential-env NAME Environment variable containing the credential
--display-name TEXT Public listing name
--build-version TEXT Public build version
--metadata KEY=VALUE Bounded public metadata; may be repeated
--fallback IP:PORT Optional policy-authorized dedicated fallback
--run-seconds NUMBER Stop after 1-86400 seconds
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
Join options:
--listing UUID Join an exact listing; otherwise browse/select
Credentials are accepted only through the named environment variable. They are never
accepted on the command line and are never written to human or JSON output.
""";
internal static TestClientParseResult Parse(string[] args)
{
if (args.Length == 0 || args.Length == 1 && IsHelp(args[0]))
{
return TestClientParseResult.Help();
}
if (args.Length > 64)
{
return TestClientParseResult.Failure("Too many command-line arguments.");
}
if (!TryMode(args[0], out TestClientMode mode))
{
return TestClientParseResult.Failure("The first argument must be host, browse, or join.");
}
Uri serviceUri = new("http://127.0.0.1:5000/");
IPEndPoint mediator = new(IPAddress.Loopback, 9050);
string game = "space-game";
string environment = "development";
string region = "local";
uint protocol = 1;
string buildVersion = "test-client";
string displayName = "Rendezvous diagnostic host";
string credentialEnvironmentVariable = "RENDEZVOUS_PUBLISHER_CREDENTIAL";
Dictionary<string, string> metadata = new(StringComparer.Ordinal);
NetworkEndpoint? dedicatedFallback = null;
SessionListingId? listingId = null;
int localPort = 0;
int pageSize = 20;
int timeoutSeconds = 20;
int? runSeconds = null;
bool script = false;
bool json = false;
bool exitAfterEcho = false;
HashSet<string> seen = new(StringComparer.Ordinal);
for (int index = 1; index < args.Length; index++)
{
string option = args[index];
if (IsHelp(option))
{
return TestClientParseResult.Help();
}
if (option is "--script" or "--json" or "--exit-after-echo")
{
if (!seen.Add(option))
{
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
}
script |= option == "--script";
json |= option == "--json";
exitAfterEcho |= option == "--exit-after-echo";
continue;
}
if (!option.StartsWith("--", StringComparison.Ordinal)
|| index + 1 >= args.Length)
{
return TestClientParseResult.Failure("Every option must use the form --name value.");
}
string value = args[++index];
if (value.Length is 0 or > 512)
{
return TestClientParseResult.Failure($"Option {option} has an invalid value length.");
}
if (option != "--metadata" && !seen.Add(option))
{
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
}
switch (option)
{
case "--service":
if (!TryServiceUri(value, out serviceUri))
{
return TestClientParseResult.Failure("The service URL must be absolute HTTP(S), credential-free, and query-free.");
}
break;
case "--mediator":
if (!IPEndPoint.TryParse(value, out IPEndPoint? parsedMediator)
|| parsedMediator.Port == 0)
{
return TestClientParseResult.Failure("The mediator must be an IP endpoint with a non-zero port.");
}
mediator = parsedMediator;
break;
case "--game":
game = value;
break;
case "--environment":
environment = value;
break;
case "--region":
region = value;
break;
case "--protocol":
if (!uint.TryParse(value, out protocol) || protocol == 0)
{
return TestClientParseResult.Failure("The protocol must be a positive integer.");
}
break;
case "--build-version":
buildVersion = value;
break;
case "--display-name":
displayName = value;
break;
case "--publisher-credential-env":
if (!IsEnvironmentVariableName(value))
{
return TestClientParseResult.Failure("The credential environment-variable name is invalid.");
}
credentialEnvironmentVariable = value;
break;
case "--metadata":
if (!TryMetadata(value, metadata))
{
return TestClientParseResult.Failure("Metadata must be a unique KEY=VALUE pair with a non-empty key.");
}
break;
case "--fallback":
if (!IPEndPoint.TryParse(value, out IPEndPoint? fallbackEndpoint)
|| fallbackEndpoint.Port == 0)
{
return TestClientParseResult.Failure("The fallback must be an IP endpoint with a non-zero port.");
}
dedicatedFallback = new NetworkEndpoint
{
AddressFamily = fallbackEndpoint.AddressFamily == AddressFamily.InterNetwork
? AddressFamilyKind.Ipv4
: AddressFamilyKind.Ipv6,
Address = fallbackEndpoint.Address.ToString(),
Port = fallbackEndpoint.Port,
};
break;
case "--listing":
if (!Guid.TryParse(value, out Guid parsedListing) || parsedListing == Guid.Empty)
{
return TestClientParseResult.Failure("The listing must be a non-empty UUID.");
}
listingId = new SessionListingId(parsedListing);
break;
case "--port":
if (!int.TryParse(value, out localPort) || localPort is < 0 or > 65_535)
{
return TestClientParseResult.Failure("The local UDP port must be between 0 and 65535.");
}
break;
case "--page-size":
if (!int.TryParse(value, out pageSize)
|| pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
{
return TestClientParseResult.Failure("The page size is outside the contract limit.");
}
break;
case "--timeout-seconds":
if (!int.TryParse(value, out timeoutSeconds) || timeoutSeconds is < 1 or > 300)
{
return TestClientParseResult.Failure("The timeout must be between 1 and 300 seconds.");
}
break;
case "--run-seconds":
if (!int.TryParse(value, out int parsedRunSeconds)
|| parsedRunSeconds is < 1 or > 86_400)
{
return TestClientParseResult.Failure("The host run duration must be between 1 and 86400 seconds.");
}
runSeconds = parsedRunSeconds;
break;
default:
return TestClientParseResult.Failure($"Unknown option {option}.");
}
}
if (!IsSlug(game, ContractLimits.GameIdMaxCharacters)
|| !IsSlug(environment, ContractLimits.EnvironmentIdMaxCharacters)
|| !IsSlug(region, ContractLimits.RegionIdMaxCharacters)
|| !ContractValidation.IsBuildVersionValid(buildVersion)
|| !ContractValidation.IsDisplayNameValid(displayName)
|| !ContractValidation.IsMetadataValid(metadata))
{
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
}
if (listingId.HasValue && mode != TestClientMode.Join
|| runSeconds.HasValue && mode != TestClientMode.Host
|| exitAfterEcho && mode != TestClientMode.Host
|| metadata.Count > 0 && mode != TestClientMode.Host
|| dedicatedFallback is not null && mode != TestClientMode.Host
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|| seen.Contains("--display-name") && mode != TestClientMode.Host
|| seen.Contains("--build-version") && mode != TestClientMode.Host)
{
return TestClientParseResult.Failure("One or more options do not apply to the selected mode.");
}
return TestClientParseResult.Success(new TestClientOptions
{
Mode = mode,
ServiceUri = serviceUri,
Mediator = mediator,
GameId = new(game),
EnvironmentId = new(environment),
RegionId = new(region),
ProtocolVersion = protocol,
BuildVersion = buildVersion,
DisplayName = displayName,
PublisherCredentialEnvironmentVariable = credentialEnvironmentVariable,
Metadata = metadata,
DedicatedFallback = dedicatedFallback,
ListingId = listingId,
LocalPort = localPort,
PageSize = pageSize,
OperationTimeout = TimeSpan.FromSeconds(timeoutSeconds),
RunDuration = runSeconds.HasValue
? TimeSpan.FromSeconds(runSeconds.Value)
: mode == TestClientMode.Host && script
? TimeSpan.FromSeconds(timeoutSeconds)
: null,
Script = script,
Json = json,
ExitAfterEcho = exitAfterEcho,
});
}
private static bool TryMode(string value, out TestClientMode mode) =>
Enum.TryParse(value, true, out mode) && Enum.IsDefined(mode);
private static bool IsHelp(string value) => value is "--help" or "-h" or "help";
private static bool TryServiceUri(string value, out Uri uri)
{
uri = null!;
if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed)
|| parsed.Scheme is not ("http" or "https")
|| !string.IsNullOrEmpty(parsed.UserInfo)
|| !string.IsNullOrEmpty(parsed.Query)
|| !string.IsNullOrEmpty(parsed.Fragment))
{
return false;
}
UriBuilder builder = new(parsed) { Path = parsed.AbsolutePath.TrimEnd('/') + "/" };
uri = builder.Uri;
return true;
}
private static bool IsEnvironmentVariableName(string value)
{
if (value.Length is 0 or > 64 || !(char.IsLetter(value[0]) || value[0] == '_'))
{
return false;
}
return value.All(static character =>
char.IsAsciiLetterOrDigit(character) || character == '_');
}
private static bool TryMetadata(string value, Dictionary<string, string> metadata)
{
int separator = value.IndexOf('=');
if (separator is < 1 or > ContractLimits.MetadataKeyMaxBytes
|| metadata.Count >= ContractLimits.MetadataMaxKeys)
{
return false;
}
string key = value[..separator];
string metadataValue = value[(separator + 1)..];
return !string.IsNullOrWhiteSpace(key)
&& ContractValidation.IsUtf8LengthWithin(key, ContractLimits.MetadataKeyMaxBytes)
&& ContractValidation.IsUtf8LengthWithin(metadataValue, ContractLimits.MetadataValueMaxBytes)
&& metadata.TryAdd(key, metadataValue);
}
private static bool IsSlug(string value, int maximumCharacters) =>
value.Length is > 0
&& value.Length <= maximumCharacters
&& value[0] is >= 'a' and <= 'z'
&& value.All(static character => character is >= 'a' and <= 'z'
or >= '0' and <= '9'
or '-');
}
@@ -0,0 +1,181 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
namespace FinalFactory.Rendezvous.TestClient;
internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter standardError, bool json)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
};
private readonly object _gate = new();
private readonly TextWriter _standardOutput = standardOutput ?? throw new ArgumentNullException(nameof(standardOutput));
private readonly TextWriter _standardError = standardError ?? throw new ArgumentNullException(nameof(standardError));
private readonly bool _json = json;
internal void Write(
string eventName,
string status,
string? phase = null,
string? listingId = null,
string? displayName = null,
string? outcome = null,
string? endpointType = null,
int? count = null,
long? elapsedMilliseconds = null,
string? message = null) => WriteCore(
_standardOutput,
new TestClientEvent
{
Event = SafeToken(eventName) ?? string.Empty,
Status = SafeToken(status) ?? string.Empty,
Phase = SafeToken(phase),
ListingId = SafeToken(listingId),
DisplayName = SafeText(displayName),
Outcome = SafeToken(outcome),
EndpointType = SafeToken(endpointType),
Count = count,
ElapsedMilliseconds = elapsedMilliseconds,
Message = SafeText(message),
});
internal void WriteError(
string eventName,
string status,
string message,
string? phase = null,
string? outcome = null) => WriteCore(
_standardError,
new TestClientEvent
{
Event = SafeToken(eventName) ?? string.Empty,
Status = SafeToken(status) ?? string.Empty,
Phase = SafeToken(phase),
Outcome = SafeToken(outcome),
Message = SafeText(message),
});
internal void WritePrompt(string prompt)
{
if (_json)
{
return;
}
lock (_gate)
{
_standardOutput.Write(SafeText(prompt));
_standardOutput.Flush();
}
}
private void WriteCore(TextWriter writer, TestClientEvent item)
{
string line = _json
? JsonSerializer.Serialize(item, JsonOptions)
: HumanLine(item);
lock (_gate)
{
writer.WriteLine(line);
writer.Flush();
}
}
private static string HumanLine(TestClientEvent item)
{
StringBuilder line = new();
line.Append('[').Append(item.Status).Append("] ").Append(item.Event);
Append(line, "phase", item.Phase);
Append(line, "listing", item.ListingId);
Append(line, "name", item.DisplayName, quote: true);
Append(line, "outcome", item.Outcome);
Append(line, "endpoint", item.EndpointType);
if (item.Count.HasValue)
{
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
if (item.ElapsedMilliseconds.HasValue)
{
Append(
line,
"elapsedMs",
item.ElapsedMilliseconds.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
Append(line, "message", item.Message, quote: true);
return line.ToString();
}
private static void Append(
StringBuilder builder,
string name,
string? value,
bool quote = false)
{
if (!string.IsNullOrEmpty(value))
{
builder.Append(' ').Append(name).Append('=');
if (quote)
{
builder.Append('"').Append(value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"');
}
else
{
builder.Append(value);
}
}
}
private static string? SafeToken(string? value)
{
if (value is null)
{
return null;
}
return new string(value
.Take(96)
.Select(static character => char.IsAsciiLetterOrDigit(character)
|| character is '.' or '-' or '_' or ':'
? char.ToLowerInvariant(character)
: '_')
.ToArray());
}
private static string? SafeText(string? value)
{
if (value is null)
{
return null;
}
return new string(value
.Take(160)
.Select(static character => IsUnsafeHumanCharacter(character) ? '?' : character)
.ToArray());
}
private static bool IsUnsafeHumanCharacter(char character) =>
char.GetUnicodeCategory(character) is
UnicodeCategory.Control
or UnicodeCategory.Format
or UnicodeCategory.LineSeparator
or UnicodeCategory.ParagraphSeparator
or UnicodeCategory.Surrogate
or UnicodeCategory.PrivateUse;
private sealed class TestClientEvent
{
public int Version { get; init; } = 1;
public string Event { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string? Phase { get; init; }
public string? ListingId { get; init; }
public string? DisplayName { get; init; }
public string? Outcome { get; init; }
public string? EndpointType { get; init; }
public int? Count { get; init; }
public long? ElapsedMilliseconds { get; init; }
public string? Message { get; init; }
}
}
@@ -102,6 +102,32 @@ public sealed class RendezvousClientBehaviorTests
Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays)); Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays));
} }
[Fact]
public async Task SilentServiceIsBoundedByTheConfiguredRequestTimeout()
{
using HttpClient httpClient = new(new SilentHandler())
{
BaseAddress = new("http://rendezvous.test/"),
};
RendezvousSessionBrowserClient browser = new(
httpClient,
new RendezvousClientOptions
{
MaximumSafeRetries = 0,
RequestTimeout = TimeSpan.FromMilliseconds(20),
JitterRatio = 0,
});
RendezvousClientResult<BrowseSessionsResponse> result = await browser.BrowseAsync(new()
{
GameId = new("space-game"),
EnvironmentId = new("production"),
ProtocolVersion = 7,
}).WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, result.Error);
}
[Fact] [Fact]
public void SuccessResultRequiresAValue() public void SuccessResultRequiresAValue()
{ {
@@ -339,4 +365,15 @@ public sealed class RendezvousClientBehaviorTests
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
} }
} }
private sealed class SilentHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK);
}
}
} }
@@ -1,5 +1,6 @@
using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
@@ -159,6 +160,8 @@ public sealed class RendezvousClientIntegrationTests
options.ThrowOnBadRequest = true); options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>(); builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddOptions<AbuseProtectionOptions>();
builder.Services.AddSingleton<AbuseProtectionService>();
builder.Services.AddSingleton(provisioning); builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Credentials); builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization); builder.Services.AddSingleton(provisioning.PublisherAuthorization);
@@ -173,6 +176,7 @@ public sealed class RendezvousClientIntegrationTests
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
await app.StartAsync(); await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>(); IServer server = app.Services.GetRequiredService<IServer>();
@@ -0,0 +1,885 @@
using System.Net;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousCoordinatorBehaviorTests
{
[Fact]
public void LegacyCompletionConstructorsRemainCompatibleWithoutAllowingNonterminalStates()
{
#pragma warning disable CS0618
RendezvousConnectionCompletedEventArgs client = new(
RendezvousConnectionState.Rejected,
(NetPeer?)null);
RendezvousHostAttemptCompletedEventArgs host = new(
new JoinAttemptId(Guid.NewGuid()),
RendezvousConnectionState.ManagerStopped,
(NetPeer?)null);
Assert.Throws<ArgumentOutOfRangeException>(() =>
new RendezvousConnectionCompletedEventArgs(
RendezvousConnectionState.Punching,
(NetPeer?)null));
Assert.Throws<ArgumentException>(() =>
new RendezvousHostAttemptCompletedEventArgs(
default,
RendezvousConnectionState.Rejected,
(NetPeer?)null));
#pragma warning restore CS0618
Assert.Equal(ConnectionOutcomeKind.HostRejected, client.Outcome.Kind);
Assert.Equal(ConnectionOutcomeKind.ManagerStopped, host.Outcome.Kind);
}
[Theory]
[InlineData(RendezvousErrorCode.NotFound, ConnectionOutcomeKind.DirectoryNotFound, RendezvousConnectionFailureCategory.Directory)]
[InlineData(RendezvousErrorCode.Expired, ConnectionOutcomeKind.AttemptExpired, RendezvousConnectionFailureCategory.Authorization)]
[InlineData(RendezvousErrorCode.IncompatibleProtocol, ConnectionOutcomeKind.IncompatibleProtocol, RendezvousConnectionFailureCategory.Compatibility)]
[InlineData(RendezvousErrorCode.Forbidden, ConnectionOutcomeKind.Unauthorized, RendezvousConnectionFailureCategory.Authorization)]
[InlineData(RendezvousErrorCode.RateLimited, ConnectionOutcomeKind.RateLimited, RendezvousConnectionFailureCategory.Capacity)]
[InlineData(RendezvousErrorCode.StaleHost, ConnectionOutcomeKind.NoHostPresence, RendezvousConnectionFailureCategory.HostPresence)]
[InlineData(RendezvousErrorCode.ServiceUnavailable, ConnectionOutcomeKind.ServiceUnavailable, RendezvousConnectionFailureCategory.Service)]
public void AuthoritativeServiceErrorsMapToStableConnectionOutcomes(
RendezvousErrorCode error,
ConnectionOutcomeKind expectedKind,
RendezvousConnectionFailureCategory expectedCategory)
{
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.FromServiceError(
error,
TimeSpan.FromMilliseconds(250));
Assert.Equal(expectedKind, outcome.Kind);
Assert.Equal(expectedCategory, outcome.Category);
Assert.Equal(RendezvousConnectionOutcomeSource.RendezvousService, outcome.Source);
Assert.Equal(error, outcome.ServiceError);
}
[Fact]
public void NatIntroductionAloneDoesNotCompleteTheClientAttempt()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
int completions = 0;
harness.Coordinator.Completed += (_, _) => completions++;
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
harness.IntroductionToken);
Assert.Equal(RendezvousConnectionState.Connecting, harness.Coordinator.State);
Assert.False(harness.Coordinator.IsCompleted);
Assert.Equal(0, completions);
}
[Fact]
public void ClientRejectsASyntacticallyValidIntroductionWithTheWrongTicket()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
string forged = NatIntroductionTokenCodec.Encode(
harness.AttemptId,
Credential('F'));
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
forged);
Assert.Equal(RendezvousConnectionState.Punching, harness.Coordinator.State);
Assert.False(harness.Coordinator.IsCompleted);
}
[Fact]
public void MediatorNetworkErrorProducesOneTypedTerminalOutcome()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
int completions = 0;
harness.Coordinator.Completed += (_, _) => completions++;
harness.NetworkEvents.OnNetworkError(
new IPEndPoint(IPAddress.Loopback, 65_001),
System.Net.Sockets.SocketError.HostUnreachable);
RendezvousConnectionOutcome outcome = Assert.IsType<RendezvousConnectionOutcome>(
harness.Coordinator.Outcome);
harness.NetworkEvents.OnNetworkError(
new IPEndPoint(IPAddress.Loopback, 65_001),
System.Net.Sockets.SocketError.HostUnreachable);
Assert.Equal(ConnectionOutcomeKind.MediatorUnavailable, outcome.Kind);
Assert.Equal(RendezvousConnectionFailureCategory.Mediation, outcome.Category);
Assert.Equal(1, completions);
Assert.Same(outcome, harness.Coordinator.Outcome);
}
[Fact]
public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
List<RendezvousConnectionState> completions = [];
harness.Coordinator.Completed += (_, completion) => completions.Add(completion.State);
harness.Coordinator.Cancel();
harness.Coordinator.Poll();
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
harness.IntroductionToken);
harness.Coordinator.Poll();
Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State);
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
Assert.Equal(ConnectionOutcomeKind.Cancelled, harness.Coordinator.Outcome!.Kind);
}
[Fact]
public void ExhaustedPunchBudgetTimesOutExactlyOnceUnderAFakeClock()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
{
MaximumPunchRequests = 1,
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(10),
JitterRatio = 0,
});
int completions = 0;
harness.Coordinator.Completed += (_, _) => completions++;
harness.Coordinator.Poll();
clock.Advance(TimeSpan.FromMilliseconds(10));
harness.Coordinator.Poll();
clock.Advance(TimeSpan.FromMinutes(1));
harness.Coordinator.Poll();
Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State);
Assert.Equal(1, completions);
Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, harness.Coordinator.Outcome!.Kind);
Assert.Equal(
RendezvousConnectionFailureCategory.NatTraversal,
harness.Coordinator.Outcome.Category);
}
[Fact]
public void WallClockRollbackCannotExtendTheMonotonicPunchDeadline()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
{
PunchTimeout = TimeSpan.FromSeconds(10),
JitterRatio = 0,
});
clock.AdjustWallClock(TimeSpan.FromHours(-1));
clock.Advance(TimeSpan.FromSeconds(11));
harness.Coordinator.Poll();
Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, harness.Coordinator.Outcome!.Kind);
Assert.Equal(TimeSpan.FromSeconds(11), harness.Coordinator.Outcome.Elapsed);
}
[Fact]
public void DirectConnectTimeoutOffersFallbackWithoutConnectingIt()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
NetworkEndpoint fallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.90",
Port = 9_060,
};
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
{
DirectConnectTimeout = TimeSpan.FromMilliseconds(10),
DedicatedFallbackOverride = fallback,
JitterRatio = 0,
});
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
harness.IntroductionToken);
clock.Advance(TimeSpan.FromMilliseconds(10));
harness.Coordinator.Poll();
RendezvousConnectionOutcome outcome = Assert.IsType<RendezvousConnectionOutcome>(
harness.Coordinator.Outcome);
Assert.Equal(ConnectionOutcomeKind.DirectConnectTimedOut, outcome.Kind);
Assert.Equal(RendezvousConnectionPhase.DirectConnection, outcome.Phase);
Assert.Equal("203.0.113.90", outcome.DedicatedFallback!.Address);
List<NetPeer> connectedPeers = [];
harness.Manager.GetConnectedPeers(connectedPeers);
Assert.Empty(connectedPeers);
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_001),
NatAddressType.External,
harness.IntroductionToken);
Assert.Same(outcome, harness.Coordinator.Outcome);
}
[Fact]
public void ManagerShutdownAndDisposalEachReleaseTheirTerminalPathOnce()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness stopped = new(clock);
int stoppedCompletions = 0;
stopped.Coordinator.Completed += (_, _) => stoppedCompletions++;
stopped.Manager.Stop();
stopped.Coordinator.Poll();
stopped.Coordinator.Poll();
Assert.Equal(RendezvousConnectionState.ManagerStopped, stopped.Coordinator.State);
Assert.Equal(1, stoppedCompletions);
using ClientHarness disposed = new(clock);
int disposedCompletions = 0;
disposed.Coordinator.Completed += (_, _) => disposedCompletions++;
disposed.Coordinator.Dispose();
disposed.Coordinator.Dispose();
((INatPunchListener)disposed.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
disposed.IntroductionToken);
Assert.Equal(RendezvousConnectionState.Disposed, disposed.Coordinator.State);
Assert.Equal(1, disposedCompletions);
Assert.Throws<ObjectDisposedException>(() => disposed.Coordinator.Poll());
}
[Fact]
public async Task DirectConnectionRejectionProducesOneTerminalTransition()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness client = new(clock);
EventBasedNetListener rejectingEvents = new();
rejectingEvents.ConnectionRequestEvent += request => request.Reject();
NetManager rejectingHost = new(rejectingEvents);
try
{
Assert.True(rejectingHost.Start(0));
int completions = 0;
client.Coordinator.Completed += (_, _) => completions++;
((INatPunchListener)client.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, rejectingHost.LocalPort),
NatAddressType.External,
client.IntroductionToken);
DateTime deadline = DateTime.UtcNow.AddSeconds(2);
while (!client.Coordinator.IsCompleted && DateTime.UtcNow < deadline)
{
rejectingHost.PollEvents();
client.Coordinator.Poll();
await Task.Delay(2);
}
Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State);
Assert.Equal(1, completions);
Assert.Equal(ConnectionOutcomeKind.HostRejected, client.Coordinator.Outcome!.Kind);
Assert.Equal(
RendezvousConnectionOutcomeSource.RemoteHost,
client.Coordinator.Outcome.Source);
client.Coordinator.Poll();
Assert.Equal(1, completions);
}
finally
{
rejectingHost.Stop();
}
}
[Fact]
public void UnsynchronizedLiteNetCallbacksAreRejectedAtConstruction()
{
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
manager.UnsyncedEvents = true;
try
{
Assert.True(manager.Start(0));
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 9_050),
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
}
finally
{
manager.Stop();
}
}
[Fact]
public void CoordinatorRejectsAManagerCreatedByAnotherRoutingListener()
{
RendezvousNetListener managerEvents = new();
NetManager manager = managerEvents.CreateManager();
RendezvousNetListener mismatchedEvents = new();
try
{
Assert.True(manager.Start(0));
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
manager,
mismatchedEvents,
new IPEndPoint(IPAddress.Loopback, 9_050),
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task PublishedSessionTimingRemainsValidDuringConcurrentRenewalReads()
{
DateTimeOffset firstExpiry = new(2030, 1, 1, 0, 1, 0, TimeSpan.Zero);
DateTimeOffset secondExpiry = new(2030, 1, 1, 0, 2, 0, TimeSpan.Zero);
PublishedSession session = CreateSession(firstExpiry);
Task writer = Task.Run(() =>
{
for (int index = 0; index < 10_000; index++)
{
session.ExpiresAt = index % 2 == 0 ? firstExpiry : secondExpiry;
session.LeaseRenewAfterSeconds = index % 2 == 0 ? 10 : 20;
}
});
Task reader = Task.Run(() =>
{
for (int index = 0; index < 10_000; index++)
{
DateTimeOffset expiry = session.ExpiresAt;
int renewAfter = session.LeaseRenewAfterSeconds;
Assert.True(expiry == firstExpiry || expiry == secondExpiry);
Assert.True(renewAfter is 10 or 20 or 30);
}
});
await Task.WhenAll(writer, reader);
}
[Fact]
public async Task HostDoesNotMistakeAnIntroducedAttemptForCancellationWhenItLeavesPolling()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000111"));
HostJoinAttempt invitation = new()
{
AttemptId = attemptId,
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000112")),
HostPunchCapability = Credential('H'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(attemptId, Credential('T'))),
ExpiresAt = clock.UtcNow + TimeSpan.FromSeconds(30),
};
MutableJoinClient joins = new([invitation]);
using ConnectionTicketValidator tickets = new(16, clock);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
tickets);
int completions = 0;
host.AttemptCompleted += (_, _) => completions++;
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
Assert.Equal(1, host.PendingAttemptCount);
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_003),
NatAddressType.External,
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')));
joins.Attempts = [];
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
Assert.Equal(1, host.PendingAttemptCount);
Assert.Equal(0, completions);
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostCancellationSnapshotRevokesAnAuthorizedTicketAndCompletesOnce()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000131"));
string ticket = NatIntroductionTokenCodec.Encode(attemptId, Credential('T'));
HostJoinAttempt invitation = CreateHostAttempt(
attemptId,
new(Guid.Parse("00000000-0000-0000-0000-000000000132")),
ticket,
clock.UtcNow + TimeSpan.FromSeconds(30));
MutableJoinClient joins = new([invitation]);
using ConnectionTicketValidator tickets = new(16, clock);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
tickets);
List<RendezvousConnectionState> completions = [];
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_003),
NatAddressType.External,
ticket);
invitation.IsCancelled = true;
joins.Attempts = [invitation];
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
host.Poll();
Assert.Equal(0, host.PendingAttemptCount);
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
Assert.Equal(
ConnectionTicketConsumptionResult.Revoked,
tickets.Consume(attemptId, ticket));
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostAppliesOnlyTheLatestUnpolledSnapshot()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000141"));
HostJoinAttempt invitation = CreateHostAttempt(
attemptId,
new(Guid.Parse("00000000-0000-0000-0000-000000000142")),
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')),
clock.UtcNow + TimeSpan.FromSeconds(30));
MutableJoinClient joins = new([invitation]);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
null);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
joins.Attempts = [];
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
Assert.Equal(0, host.PendingAttemptCount);
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task DisposingHostDuringRefreshDropsTheLateSnapshot()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
BlockingJoinClient joins = new();
try
{
Assert.True(manager.Start(0));
RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
null);
Task<RendezvousClientResult<int>> refresh = host.RefreshJoinAttemptsAsync();
await joins.WaitUntilCalled;
host.Dispose();
joins.Complete([]);
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await refresh);
Assert.Throws<ObjectDisposedException>(() => host.Poll());
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostDeadlinesAreNotDelayedByTheBoundedRetryQueue()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
JoinAttemptId firstId = new(Guid.Parse("00000000-0000-0000-0000-000000000151"));
JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000152"));
DateTimeOffset expiresAt = clock.UtcNow + TimeSpan.FromSeconds(1);
MutableJoinClient joins = new([
CreateHostAttempt(
firstId,
new(Guid.Parse("00000000-0000-0000-0000-000000000153")),
NatIntroductionTokenCodec.Encode(firstId, Credential('T')),
expiresAt),
CreateHostAttempt(
secondId,
new(Guid.Parse("00000000-0000-0000-0000-000000000154")),
NatIntroductionTokenCodec.Encode(secondId, Credential('U')),
expiresAt),
]);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions
{
MaximumAttemptChecksPerPoll = 1,
JitterRatio = 0,
},
clock,
null);
List<RendezvousConnectionState> completions = [];
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
clock.Advance(TimeSpan.FromSeconds(2));
host.Poll();
Assert.Equal(0, host.PendingAttemptCount);
Assert.Equal(
[RendezvousConnectionState.TimedOut, RendezvousConnectionState.TimedOut],
completions);
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostStopPublishesEveryCompletionBeforeReentrantDisposalCanTearDownState()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
JoinAttemptId firstId = new(Guid.Parse("00000000-0000-0000-0000-000000000161"));
JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000162"));
MutableJoinClient joins = new([
CreateHostAttempt(
firstId,
new(Guid.Parse("00000000-0000-0000-0000-000000000163")),
NatIntroductionTokenCodec.Encode(firstId, Credential('T')),
clock.UtcNow + TimeSpan.FromSeconds(30)),
CreateHostAttempt(
secondId,
new(Guid.Parse("00000000-0000-0000-0000-000000000164")),
NatIntroductionTokenCodec.Encode(secondId, Credential('U')),
clock.UtcNow + TimeSpan.FromSeconds(30)),
]);
RendezvousHostCoordinator? host = null;
try
{
Assert.True(manager.Start(0));
host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
null);
int completions = 0;
host.AttemptCompleted += (_, _) =>
{
completions++;
if (completions == 1)
{
host.Dispose();
}
};
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
manager.Stop();
host.Poll();
Assert.Equal(2, completions);
Assert.Equal(0, host.PendingAttemptCount);
}
finally
{
host?.Dispose();
manager.Stop();
}
}
[Fact]
public async Task HostPunchTimeoutUsesItsOwnFakeClockBudget()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000161"));
MutableJoinClient joins = new([
CreateHostAttempt(
attemptId,
new(Guid.Parse("00000000-0000-0000-0000-000000000162")),
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')),
clock.UtcNow + TimeSpan.FromSeconds(30)),
]);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions
{
MaximumPunchRequests = 20,
PunchTimeout = TimeSpan.FromMilliseconds(10),
JitterRatio = 0,
},
clock,
null);
RendezvousHostAttemptCompletedEventArgs? completion = null;
host.AttemptCompleted += (_, value) => completion = value;
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
clock.Advance(TimeSpan.FromMilliseconds(10));
host.Poll();
Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, completion!.Outcome.Kind);
Assert.Equal(TimeSpan.FromMilliseconds(10), completion.Outcome.Elapsed);
}
finally
{
manager.Stop();
}
}
private static CreateJoinAttemptResponse CreateAttempt(DateTimeOffset expiresAt) => new()
{
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")),
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000102")),
ClientPunchCapability = Credential('C'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000101")),
Credential('T'))),
ExpiresAt = expiresAt,
};
private static PublishedSession CreateSession(DateTimeOffset expiresAt) => new(new RegisterSessionResponse
{
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000121")),
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000122")),
LeaseToken = "lease-token",
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000123")),
HostPresenceCapability = Credential('P'),
ExpiresAt = expiresAt,
LeaseRenewAfterSeconds = 30,
HostPresenceRefreshAfterSeconds = 10,
});
private static HostJoinAttempt CreateHostAttempt(
JoinAttemptId attemptId,
MediationHandle mediationHandle,
string connectionTicket,
DateTimeOffset expiresAt) => new()
{
AttemptId = attemptId,
MediationHandle = mediationHandle,
HostPunchCapability = Credential('H'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(connectionTicket),
ExpiresAt = expiresAt,
};
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
private sealed class ClientHarness : IDisposable
{
internal ClientHarness(
ManualCoordinatorClock clock,
RendezvousCoordinatorOptions? options = null)
{
NetworkEvents = new();
PunchEvents = NetworkEvents.PunchEvents;
Manager = NetworkEvents.CreateManager();
Assert.True(Manager.Start(0));
CreateJoinAttemptResponse attempt = CreateAttempt(clock.UtcNow + TimeSpan.FromSeconds(30));
AttemptId = attempt.AttemptId;
IntroductionToken = NatIntroductionTokenCodec.Encode(
attempt.AttemptId,
Credential('T'));
Coordinator = new(
Manager,
NetworkEvents,
new IPEndPoint(IPAddress.Loopback, 65_001),
attempt,
options,
clock);
}
internal RendezvousNetListener NetworkEvents { get; }
internal EventBasedNatPunchListener PunchEvents { get; }
internal NetManager Manager { get; }
internal RendezvousClientCoordinator Coordinator { get; }
internal JoinAttemptId AttemptId { get; }
internal string IntroductionToken { get; }
public void Dispose()
{
Coordinator.Dispose();
Manager.Stop();
}
}
private sealed class MutableJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
{
internal IReadOnlyList<HostJoinAttempt> Attempts { get; set; } = attempts;
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default) => Task.FromResult(
RendezvousClientResult.Success(Attempts));
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class BlockingJoinClient : IRendezvousJoinClient
{
private readonly TaskCompletionSource<bool> _called = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<IReadOnlyList<HostJoinAttempt>> _result = new(
TaskCreationOptions.RunContinuationsAsynchronously);
internal Task WaitUntilCalled => _called.Task;
internal void Complete(IReadOnlyList<HostJoinAttempt> attempts) =>
_result.SetResult(attempts);
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default)
{
_called.SetResult(true);
return RendezvousClientResult.Success(await _result.Task.WaitAsync(cancellationToken));
}
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class ManualCoordinatorClock(DateTimeOffset now) :
IRendezvousCoordinatorClock,
IConnectionTicketClock
{
public DateTimeOffset UtcNow { get; private set; } = now;
public TimeSpan Elapsed { get; private set; }
internal void Advance(TimeSpan amount)
{
UtcNow += amount;
Elapsed += amount;
}
internal void AdjustWallClock(TimeSpan amount) => UtcNow += amount;
}
}
@@ -0,0 +1,283 @@
using System.Net;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.JoinAttempts;
using LiteNetLib;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousCoordinatorIntegrationTests
{
[Fact]
public async Task CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(8));
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "sdk-direct-connect");
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService mediatorService = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await mediatorService.StartAsync(timeout.Token);
RendezvousNetListener hostEvents = new();
RendezvousNetListener clientEvents = new();
bool gameplayConnectionRequestHandled = false;
hostEvents.GameplayEvents.ConnectionRequestEvent += _ =>
gameplayConnectionRequestHandled = true;
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
EventBasedNatPunchListener clientPunch = clientEvents.PunchEvents;
NetManager hostManager = hostEvents.CreateManager();
NetManager clientManager = clientEvents.CreateManager();
string? introductionToken = null;
string? hostIntroductionToken = null;
clientPunch.NatIntroductionSuccess += (_, _, token) => introductionToken = token;
hostPunch.NatIntroductionSuccess += (_, _, token) => hostIntroductionToken = token;
try
{
Assert.True(hostManager.Start(0));
Assert.True(clientManager.Start(0));
IPEndPoint mediator = Assert.IsType<IPEndPoint>(mediatorService.LocalEndpoint);
FakeJoinClient joinClient = new([hostAttempt]);
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
using ConnectionTicketValidator tickets = new(1_024, clock);
using RendezvousHostCoordinator host = new(
hostManager,
hostEvents,
mediator,
new PublishedSession(registration),
joinClient,
FastOptions(),
clock,
tickets);
using RendezvousClientCoordinator client = new(
clientManager,
clientEvents,
mediator,
created,
FastOptions(),
clock);
List<RendezvousHostAttemptCompletedEventArgs> hostCompletions = [];
List<RendezvousConnectionCompletedEventArgs> clientCompletions = [];
host.AttemptCompleted += (_, completion) => hostCompletions.Add(completion);
client.Completed += (_, completion) => clientCompletions.Add(completion);
Assert.True((await host.RefreshJoinAttemptsAsync(timeout.Token)).IsSuccess);
while ((!client.IsCompleted || hostCompletions.Count == 0)
&& !timeout.IsCancellationRequested)
{
host.Poll();
client.Poll();
await Task.Delay(2);
}
Assert.True(
client.State == RendezvousConnectionState.Connected,
$"Client ended in {client.State}; host pending={host.PendingAttemptCount}; "
+ $"host completions={hostCompletions.Count}; introduction={introductionToken is not null}; "
+ $"host introduction={hostIntroductionToken is not null}; "
+ $"client digest={NatIntroductionTokenCodec.MatchesDigest(introductionToken, created.ConnectionTicketDigest)}; "
+ $"host digest={NatIntroductionTokenCodec.MatchesDigest(hostIntroductionToken, hostAttempt.ConnectionTicketDigest)}.");
Assert.NotNull(client.ConnectedPeer);
Assert.Equal(
RendezvousConnectionState.Connected,
Assert.Single(clientCompletions).State);
RendezvousHostAttemptCompletedEventArgs hostCompletion = Assert.Single(hostCompletions);
Assert.Equal(created.AttemptId, hostCompletion.AttemptId);
Assert.Equal(RendezvousConnectionState.Connected, hostCompletion.State);
Assert.NotNull(hostCompletion.Peer);
Assert.False(gameplayConnectionRequestHandled);
Assert.True(NatIntroductionTokenCodec.TryDecode(
introductionToken,
out NatIntroductionToken? introduction));
Assert.NotNull(introduction);
EventBasedNetListener replayEvents = new();
bool replayConnected = false;
replayEvents.PeerConnectedEvent += _ => replayConnected = true;
NetManager replayManager = new(replayEvents);
try
{
Assert.True(replayManager.Start(0));
replayManager.Connect(
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
DirectConnectionRequestCodec.Encode(
created.AttemptId,
introduction.ConnectionTicket));
DateTime replayDeadline = DateTime.UtcNow.AddSeconds(1);
while (DateTime.UtcNow < replayDeadline && !replayConnected)
{
host.Poll();
replayManager.PollEvents();
await Task.Delay(2, timeout.Token);
}
Assert.False(replayConnected);
Assert.False(gameplayConnectionRequestHandled);
Assert.Single(hostCompletions);
}
finally
{
replayManager.Stop();
}
}
finally
{
hostManager.Stop();
clientManager.Stop();
await mediatorService.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task HostDefersADirectRequestUntilTheMatchingNatIntroductionArrives()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "direct-before-nat");
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
ConnectionTicketGrant grant = Assert.IsType<ConnectionTicketGrant>(
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
RendezvousNetListener hostEvents = new();
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
EventBasedNetListener clientEvents = new();
bool clientConnected = false;
clientEvents.PeerConnectedEvent += _ => clientConnected = true;
NetManager hostManager = hostEvents.CreateManager();
NetManager clientManager = new(clientEvents);
try
{
Assert.True(hostManager.Start(0));
Assert.True(clientManager.Start(0));
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
FakeJoinClient joins = new([hostAttempt]);
using ConnectionTicketValidator tickets = new(16, clock);
using RendezvousHostCoordinator host = new(
hostManager,
hostEvents,
new IPEndPoint(IPAddress.Loopback, 65_000),
new PublishedSession(registration),
joins,
FastOptions(),
clock,
tickets);
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
host.AttemptCompleted += (_, completion) => completions.Add(completion);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
bool observedDeferredRequest = false;
hostEvents.RendezvousConnectionRequest += _ =>
{
observedDeferredRequest = host.DeferredRequestCount == 1;
((INatPunchListener)hostPunch).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, clientManager.LocalPort),
NatAddressType.External,
grant.Ticket);
};
clientManager.Connect(
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
DirectConnectionRequestCodec.Encode(created.AttemptId, grant.Ticket));
DateTime connectedDeadline = DateTime.UtcNow.AddSeconds(1);
while ((!clientConnected || completions.Count == 0)
&& DateTime.UtcNow < connectedDeadline)
{
host.Poll();
clientManager.PollEvents();
await Task.Delay(2);
}
Assert.True(
clientConnected,
$"Deferred observed={observedDeferredRequest}; deferred={host.DeferredRequestCount}; "
+ $"pending={host.PendingAttemptCount}; completions={completions.Count}.");
Assert.True(observedDeferredRequest);
Assert.Equal(0, host.DeferredRequestCount);
Assert.Equal(
RendezvousConnectionState.Connected,
Assert.Single(completions).State);
}
finally
{
hostManager.Stop();
clientManager.Stop();
}
}
private static RendezvousCoordinatorOptions FastOptions() => new()
{
MaximumPunchRequests = 20,
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(100),
JitterRatio = 0,
};
private sealed class FakeJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
{
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default) => Task.FromResult(
RendezvousClientResult.Success(attempts));
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class FixedCoordinatorClock(DateTimeOffset now) :
IRendezvousCoordinatorClock,
IConnectionTicketClock
{
public DateTimeOffset UtcNow { get; } = now;
public TimeSpan Elapsed => TimeSpan.Zero;
}
}
@@ -0,0 +1,290 @@
using System.Net;
using System.Text.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousJoinClientTests
{
[Fact]
public async Task JoinIssuanceRetriesTheSameIdempotentPayloadAndCancellationUsesCapability()
{
CreateJoinAttemptResponse created = CreateAttempt();
RecordingHandler handler = new(
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
JsonResponse(HttpStatusCode.Created, created),
new HttpResponseMessage(HttpStatusCode.NoContent));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(
http,
new RendezvousClientOptions { JitterRatio = 0 },
new ImmediateDelay());
CreateJoinAttemptRequest request = new()
{
IdempotencyKey = "stable-join-key",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000201")),
ProtocolVersion = 7,
};
RendezvousClientResult<CreateJoinAttemptResponse> result = await client.CreateAsync(request);
RendezvousClientResult<bool> cancelled = await client.CancelAsync(created);
Assert.True(result.IsSuccess, result.Message);
Assert.True(cancelled.IsSuccess, cancelled.Message);
Assert.Equal(handler.Requests[0].Body, handler.Requests[1].Body);
Assert.Contains("stable-join-key", handler.Requests[0].Body, StringComparison.Ordinal);
RecordedRequest cancellation = handler.Requests[2];
Assert.Equal(HttpMethod.Delete, cancellation.Method);
Assert.Equal(
created.ClientPunchCapability,
cancellation.Headers["X-Rendezvous-Client-Punch-Capability"]);
}
[Fact]
public async Task HostInvitationPollingFollowsCursorsWithTheLeaseToken()
{
HostJoinAttempt first = CreateHostAttempt("00000000-0000-0000-0000-000000000211");
HostJoinAttempt second = CreateHostAttempt("00000000-0000-0000-0000-000000000212");
RecordingHandler handler = new(
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
{
Items = [first],
NextCursor = "next page+cursor",
}),
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
{
Items = [second],
}));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
PublishedSession session = new(new RegisterSessionResponse
{
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000220")),
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000221")),
LeaseToken = "lease-secret",
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000222")),
HostPresenceCapability = Credential('P'),
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
LeaseRenewAfterSeconds = 15,
HostPresenceRefreshAfterSeconds = 10,
});
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
await client.BrowseAllForHostAsync(session);
Assert.True(result.IsSuccess, result.Message);
Assert.Equal([first.AttemptId, second.AttemptId], result.Value!.Select(item => item.AttemptId));
Assert.Equal(2, handler.Requests.Count);
Assert.All(handler.Requests, request =>
Assert.Equal("lease-secret", request.Headers["X-Rendezvous-Lease-Token"]));
Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal);
}
[Fact]
public async Task OutcomeReportingUsesTheAttemptCapabilityAndCoarseElapsedBucket()
{
CreateJoinAttemptResponse attempt = CreateAttempt();
RecordingHandler handler = new(JsonResponse(HttpStatusCode.OK, new ReportConnectionOutcomeResponse
{
Accepted = true,
IsDuplicate = false,
}));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.DirectConnectTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection,
TimeSpan.FromSeconds(6));
RendezvousClientResult<ReportConnectionOutcomeResponse> result =
await client.ReportOutcomeAsync(attempt, outcome);
Assert.True(result.IsSuccess, result.Message);
RecordedRequest request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal(
attempt.ClientPunchCapability,
request.Headers["X-Rendezvous-Client-Punch-Capability"]);
Assert.Contains("\"outcome\":\"directConnectTimedOut\"", request.Body, StringComparison.Ordinal);
Assert.Contains("\"elapsedBucket\":\"fiveToFifteenSeconds\"", request.Body, StringComparison.Ordinal);
Assert.DoesNotContain("diagnostic", request.Body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ConnectionStartReturnsATypedServiceOutcomeInsteadOfAnUnboundedFailure()
{
RecordingHandler handler = new(JsonResponse(HttpStatusCode.NotFound, new ApiError
{
Code = RendezvousErrorCode.NotFound,
Message = "listing unavailable",
}));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
NetworkEndpoint fallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.93",
Port = 9_063,
};
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
new CreateJoinAttemptRequest
{
IdempotencyKey = "typed-start",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")),
ProtocolVersion = 7,
},
fallback);
Assert.True(result.IsCompleted);
Assert.False(result.IsReadyForTraversal);
Assert.Null(result.Attempt);
Assert.Equal(ConnectionOutcomeKind.DirectoryNotFound, result.Outcome!.Kind);
Assert.Equal("203.0.113.93", result.Outcome.DedicatedFallback!.Address);
}
[Fact]
public async Task ConnectionStartReturnsCancelledForAPrecancelledCallerToken()
{
RecordingHandler handler = new();
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
using CancellationTokenSource cancellation = new();
cancellation.Cancel();
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
CreateRequest("cancelled-before-send"),
new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.90",
Port = 7777,
},
cancellationToken: cancellation.Token);
Assert.Empty(handler.Requests);
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
Assert.True(result.Outcome.HasDedicatedFallback);
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
}
[Fact]
public async Task ConnectionStartReturnsCancelledWhenCallerStopsASilentRequest()
{
CancellingHandler handler = new();
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
using CancellationTokenSource cancellation = new();
Task<RendezvousConnectionStartResult> pending = client.CreateConnectionAttemptAsync(
CreateRequest("cancelled-in-flight"),
cancellationToken: cancellation.Token);
await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
await cancellation.CancelAsync();
RendezvousConnectionStartResult result = await pending;
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
}
private static CreateJoinAttemptResponse CreateAttempt() => new()
{
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")),
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000203")),
ClientPunchCapability = Credential('C'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000202")),
Credential('T'))),
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
};
private static CreateJoinAttemptRequest CreateRequest(string idempotencyKey) => new()
{
IdempotencyKey = idempotencyKey,
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")),
ProtocolVersion = 7,
};
private static HostJoinAttempt CreateHostAttempt(string id) => new()
{
AttemptId = new(Guid.Parse(id)),
MediationHandle = new(Guid.NewGuid()),
HostPunchCapability = Credential('H'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(new JoinAttemptId(Guid.Parse(id)), Credential('T'))),
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
};
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
private static HttpResponseMessage JsonResponse<T>(HttpStatusCode status, T value) => new(status)
{
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options)),
};
private sealed class RecordingHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
internal List<RecordedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Dictionary<string, string> headers = request.Headers.ToDictionary(
static item => item.Key,
static item => string.Join(",", item.Value),
StringComparer.OrdinalIgnoreCase);
Requests.Add(new(
request.Method,
request.RequestUri!,
headers,
request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken)));
return _responses.Dequeue();
}
}
private sealed class CancellingHandler : HttpMessageHandler
{
internal TaskCompletionSource Started { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
_ = request;
Started.TrySetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("The silent request unexpectedly completed.");
}
}
private sealed record RecordedRequest(
HttpMethod Method,
Uri Uri,
IReadOnlyDictionary<string, string> Headers,
string Body);
private sealed class ImmediateDelay : IRendezvousDelay
{
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,175 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Tests.JoinAttempts;
namespace FinalFactory.Rendezvous.Tests.ConnectionOutcomes;
public sealed class ConnectionOutcomeServiceTests
{
[Fact]
public void ReportRemainsAuthenticatedAfterAttemptExpiryAndCountsOnlyOnce()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeMetrics metrics = new();
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
metrics);
ReportConnectionOutcomeRequest report = new()
{
Outcome = ConnectionOutcomeKind.PunchTimedOut,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
};
fixture.Sessions.Clock.Advance(fixture.Sessions.StoreOptions.JoinAttemptLifetime);
ConnectionOutcomeServiceResult first = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
report);
ConnectionOutcomeServiceResult duplicate = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
report);
ConnectionOutcomeServiceResult conflict = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
new ReportConnectionOutcomeRequest
{
Outcome = ConnectionOutcomeKind.Connected,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
});
Assert.True(first.Succeeded);
Assert.False(first.Value!.IsDuplicate);
Assert.True(duplicate.Succeeded);
Assert.True(duplicate.Value!.IsDuplicate);
Assert.Equal(RendezvousErrorCode.ReplayRejected, conflict.Error);
Assert.Equal(
1,
metrics.GetCount(
ConnectionOutcomeKind.PunchTimedOut,
ConnectionElapsedBucket.FiveToFifteenSeconds));
Assert.Equal(
RendezvousErrorCode.NotFound,
service.Report(
attempt.AttemptId,
new string('X', ContractLimits.DerivedCredentialCharacters),
report).Error);
}
[Theory]
[InlineData(ConnectionOutcomeKind.DirectoryNotFound)]
[InlineData(ConnectionOutcomeKind.IncompatibleProtocol)]
[InlineData(ConnectionOutcomeKind.Unauthorized)]
[InlineData(ConnectionOutcomeKind.RateLimited)]
public void ReportRejectsOutcomesThatCouldNotHaveAnIssuedAttempt(
ConnectionOutcomeKind outcome)
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
new ConnectionOutcomeMetrics());
ConnectionOutcomeServiceResult result = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
new ReportConnectionOutcomeRequest
{
Outcome = outcome,
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
});
Assert.Equal(RendezvousErrorCode.InvalidRequest, result.Error);
}
[Fact]
public void ListingDeletionRemovesRetainedOutcomeAuthorization()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
new ConnectionOutcomeMetrics());
Assert.True(fixture.Sessions.Store.RevokeListing(registration.ListingId).Succeeded);
Assert.Equal(
RendezvousErrorCode.NotFound,
service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
new ReportConnectionOutcomeRequest
{
Outcome = ConnectionOutcomeKind.Cancelled,
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
}).Error);
}
[Fact]
public void PrincipalRevocationRemovesReportAuthorizationAfterAttemptExpiry()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
new ConnectionOutcomeMetrics());
fixture.Sessions.Clock.Advance(fixture.Sessions.StoreOptions.JoinAttemptLifetime);
Assert.True(fixture.Sessions.Store.RevokePrincipal(
fixture.ClientSubject,
TimeSpan.FromMinutes(1)).Succeeded);
Assert.Equal(
RendezvousErrorCode.NotFound,
service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
new ReportConnectionOutcomeRequest
{
Outcome = ConnectionOutcomeKind.Cancelled,
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
}).Error);
}
[Fact]
public void FrozenV1ReportFieldsAreAcceptedButNormalizedBeforeRetention()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeMetrics metrics = new();
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
metrics);
#pragma warning disable CS0618 // Deliberately exercises the frozen legacy input surface.
ReportConnectionOutcomeRequest legacy = new()
{
Outcome = ConnectionOutcomeKind.TimedOut,
ElapsedMilliseconds = 6_000,
DiagnosticCode = "legacy-text-is-discarded",
};
#pragma warning restore CS0618
ConnectionOutcomeServiceResult result = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
legacy);
Assert.True(result.Succeeded);
Assert.Equal(
1,
metrics.GetCount(
ConnectionOutcomeKind.PunchTimedOut,
ConnectionElapsedBucket.FiveToFifteenSeconds));
}
}
@@ -70,10 +70,10 @@ public sealed class ContractSerializationTests
public void UnknownEnumNamesAndNumericValuesAreRejected() public void UnknownEnumNamesAndNumericValuesAreRejected()
{ {
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>( Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}", "{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedBucket\":\"underOneSecond\"}",
ContractJson.Options)); ContractJson.Options));
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>( Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}", "{\"contractVersion\":1,\"outcome\":99,\"elapsedBucket\":\"underOneSecond\"}",
ContractJson.Options)); ContractJson.Options));
} }
@@ -98,6 +98,7 @@ public sealed class ContractSerializationTests
[Fact] [Fact]
public void SharedCanonicalOptionsCannotBeMutatedByConsumers() public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
{ {
Assert.Equal(9, ContractJson.Options.MaxDepth);
Assert.True(ContractJson.Options.IsReadOnly); Assert.True(ContractJson.Options.IsReadOnly);
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
ContractJson.Options.WriteIndented = true); ContractJson.Options.WriteIndented = true);
@@ -0,0 +1,61 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class NatPunchRequestTokenCodecTests
{
[Theory]
[InlineData(NatPunchPeerRole.HostPresence)]
[InlineData(NatPunchPeerRole.Host)]
[InlineData(NatPunchPeerRole.Client)]
public void FixedSizeTokensRoundTripBelowLiteNetLibLimit(NatPunchPeerRole role)
{
MediationHandle handle = new(Guid.NewGuid());
const string capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
string encoded = NatPunchRequestTokenCodec.Encode(role, handle, capability);
Assert.Equal(NatPunchRequestTokenCodec.EncodedLength, encoded.Length);
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
Assert.True(NatPunchRequestTokenCodec.TryDecode(encoded, out NatPunchRequestToken? decoded));
Assert.NotNull(decoded);
Assert.Equal(role, decoded.Role);
Assert.Equal(handle, decoded.MediationHandle);
Assert.Equal(capability, decoded.Capability);
Assert.DoesNotContain(capability, decoded.ToString(), StringComparison.Ordinal);
}
[Fact]
public void MalformedAndNonCanonicalTokensAreRejected()
{
string valid = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Client,
new MediationHandle(Guid.Parse("00112233-4455-6677-8899-aabbccddeeff")),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
string uppercaseHandle = valid[..6]
+ valid.Substring(6, 32).ToUpperInvariant()
+ valid[38..];
Assert.False(NatPunchRequestTokenCodec.TryDecode(null, out _));
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1], out _));
Assert.False(NatPunchRequestTokenCodec.TryDecode("x" + valid[1..], out _));
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1] + "x", out _));
Assert.False(NatPunchRequestTokenCodec.TryDecode(uppercaseHandle, out _));
Assert.Throws<ArgumentException>(() => NatPunchRequestTokenCodec.Encode(
(NatPunchPeerRole)99,
new MediationHandle(Guid.NewGuid()),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
}
[Fact]
public void FixedWireLengthHasADedicatedLiteNetSafeContractLimit()
{
Assert.Equal(192, ContractLimits.NatPunchRequestTokenCharacters);
Assert.Equal(
ContractLimits.NatPunchRequestTokenCharacters,
NatPunchRequestTokenCodec.EncodedLength);
Assert.True(
ContractLimits.NatPunchRequestTokenCharacters
<= ContractLimits.LiteNetLibNatTokenMaxCharacters);
}
}
@@ -11,6 +11,11 @@ public sealed class OpenApiCompatibilityTests
"/v1/join-attempts", "/v1/join-attempts",
"/v1/join-attempts/{attemptId}", "/v1/join-attempts/{attemptId}",
"/v1/join-attempts/{attemptId}/outcome", "/v1/join-attempts/{attemptId}/outcome",
"/v1/operator/drain",
"/v1/operator/keys/revoke",
"/v1/operator/listings/revoke",
"/v1/operator/principals/revoke",
"/v1/operator/status",
"/v1/sessions", "/v1/sessions",
"/v1/sessions/{listingId}", "/v1/sessions/{listingId}",
"/v1/sessions/{listingId}/join-attempts", "/v1/sessions/{listingId}/join-attempts",
@@ -22,6 +27,7 @@ public sealed class OpenApiCompatibilityTests
"buildVersion", "buildVersion",
"capacity", "capacity",
"contractVersion", "contractVersion",
"dedicatedFallback",
"displayName", "displayName",
"environmentId", "environmentId",
"gameId", "gameId",
@@ -63,14 +69,51 @@ public sealed class OpenApiCompatibilityTests
Assert.Equal(ExpectedListingProperties, listingProperties); Assert.Equal(ExpectedListingProperties, listingProperties);
Assert.DoesNotContain(listingProperties, static property => Assert.DoesNotContain(listingProperties, static property =>
property.Contains("token", StringComparison.OrdinalIgnoreCase) property.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase)); || property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
.GetProperty("properties")
.GetProperty("dedicatedFallback");
JsonElement fallbackReference = Assert.Single(
dedicatedFallback.GetProperty("oneOf").EnumerateArray(),
static schema => schema.TryGetProperty("$ref", out _));
Assert.Equal(
"#/components/schemas/NetworkEndpoint",
fallbackReference.GetProperty("$ref").GetString());
JsonElement outcomeReportProperties = schemas.GetProperty("ReportConnectionOutcomeRequest")
.GetProperty("properties");
Assert.True(outcomeReportProperties.TryGetProperty("elapsedBucket", out _));
Assert.True(outcomeReportProperties.TryGetProperty("elapsedMilliseconds", out _));
Assert.True(outcomeReportProperties.TryGetProperty("diagnosticCode", out _));
string[] outcomeNames = schemas.GetProperty("ConnectionOutcomeKind")
.GetProperty("enum")
.EnumerateArray()
.Select(static value => value.GetString()!)
.ToArray();
Assert.Contains("timedOut", outcomeNames);
Assert.Contains("staleHost", outcomeNames);
Assert.Contains("transportFailed", outcomeNames);
Assert.Contains("punchTimedOut", outcomeNames);
Assert.Contains("directConnectTimedOut", outcomeNames);
Assert.Contains("transportError", outcomeNames);
JsonElement publisherBearer = root.GetProperty("components") JsonElement publisherBearer = root.GetProperty("components")
.GetProperty("securitySchemes") .GetProperty("securitySchemes")
.GetProperty("PublisherBearer"); .GetProperty("PublisherBearer");
Assert.Equal("http", publisherBearer.GetProperty("type").GetString()); Assert.Equal("http", publisherBearer.GetProperty("type").GetString());
Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString()); Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString());
JsonElement attemptCapability = root.GetProperty("components")
.GetProperty("securitySchemes")
.GetProperty("JoinAttemptCapability");
Assert.Equal("apiKey", attemptCapability.GetProperty("type").GetString());
Assert.Equal(
"X-Rendezvous-Client-Punch-Capability",
attemptCapability.GetProperty("name").GetString());
JsonElement operatorBearer = root.GetProperty("components")
.GetProperty("securitySchemes")
.GetProperty("OperatorBearer");
Assert.Equal("http", operatorBearer.GetProperty("type").GetString());
Assert.Equal("bearer", operatorBearer.GetProperty("scheme").GetString());
(string Path, string Method)[] publisherOperations = (string Path, string Method)[] publisherOperations =
[ [
("/v1/sessions", "post"), ("/v1/sessions", "post"),
@@ -87,6 +130,23 @@ public sealed class OpenApiCompatibilityTests
Assert.True(security[0].TryGetProperty("PublisherBearer", out _)); Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
} }
(string Path, string Method)[] operatorOperations =
[
("/v1/operator/status", "get"),
("/v1/operator/listings/revoke", "post"),
("/v1/operator/principals/revoke", "post"),
("/v1/operator/keys/revoke", "post"),
("/v1/operator/drain", "post"),
];
foreach ((string operationPath, string method) in operatorOperations)
{
JsonElement security = root.GetProperty("paths")
.GetProperty(operationPath)
.GetProperty(method)
.GetProperty("security");
Assert.True(security[0].TryGetProperty("OperatorBearer", out _));
}
JsonElement cancelParameters = root.GetProperty("paths") JsonElement cancelParameters = root.GetProperty("paths")
.GetProperty("/v1/join-attempts/{attemptId}") .GetProperty("/v1/join-attempts/{attemptId}")
.GetProperty("delete") .GetProperty("delete")
@@ -96,6 +156,18 @@ public sealed class OpenApiCompatibilityTests
&& parameter.GetProperty("name").GetString() && parameter.GetProperty("name").GetString()
== "X-Rendezvous-Client-Punch-Capability"); == "X-Rendezvous-Client-Punch-Capability");
Assert.True(cancelCapability.GetProperty("required").GetBoolean()); Assert.True(cancelCapability.GetProperty("required").GetBoolean());
foreach ((string operationPath, string method) in new[]
{
("/v1/join-attempts/{attemptId}", "delete"),
("/v1/join-attempts/{attemptId}/outcome", "post"),
})
{
JsonElement security = root.GetProperty("paths")
.GetProperty(operationPath)
.GetProperty(method)
.GetProperty("security");
Assert.True(security[0].TryGetProperty("JoinAttemptCapability", out _));
}
JsonElement hostPollParameters = root.GetProperty("paths") JsonElement hostPollParameters = root.GetProperty("paths")
.GetProperty("/v1/sessions/{listingId}/join-attempts") .GetProperty("/v1/sessions/{listingId}/join-attempts")
.GetProperty("get") .GetProperty("get")
@@ -104,5 +176,58 @@ public sealed class OpenApiCompatibilityTests
parameter.GetProperty("in").GetString() == "header" parameter.GetProperty("in").GetString() == "header"
&& parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token"); && parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token");
Assert.True(leaseToken.GetProperty("required").GetBoolean()); Assert.True(leaseToken.GetProperty("required").GetBoolean());
int overloadContracts = 0;
foreach (JsonProperty pathItem in root.GetProperty("paths").EnumerateObject())
{
foreach (JsonProperty operation in pathItem.Value.EnumerateObject().Where(
static item => item.Name is "get" or "post" or "put" or "delete"))
{
JsonElement responses = operation.Value.GetProperty("responses");
foreach (JsonProperty response in responses.EnumerateObject())
{
JsonElement correlation = response.Value.GetProperty("headers")
.GetProperty("X-Rendezvous-Correlation-ID");
Assert.Equal(
"string",
correlation.GetProperty("schema").GetProperty("type").GetString());
}
if (!responses.TryGetProperty("429", out JsonElement overloaded))
{
continue;
}
overloadContracts++;
JsonElement retryAfter = overloaded.GetProperty("headers")
.GetProperty("Retry-After");
Assert.Equal(
"integer",
retryAfter.GetProperty("schema").GetProperty("type").GetString());
}
}
Assert.Equal(17, overloadContracts);
(string Path, string Method)[] bodyOperations =
[
("/v1/sessions", "post"),
("/v1/sessions/{listingId}/renew", "post"),
("/v1/sessions/{listingId}", "put"),
("/v1/sessions/{listingId}", "delete"),
("/v1/join-attempts", "post"),
("/v1/join-attempts/{attemptId}/outcome", "post"),
("/v1/operator/listings/revoke", "post"),
("/v1/operator/principals/revoke", "post"),
("/v1/operator/keys/revoke", "post"),
("/v1/operator/drain", "post"),
];
foreach ((string operationPath, string method) in bodyOperations)
{
Assert.True(root.GetProperty("paths")
.GetProperty(operationPath)
.GetProperty(method)
.GetProperty("responses")
.TryGetProperty("413", out _));
}
} }
} }
@@ -0,0 +1,59 @@
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class TraversalTokenCodecTests
{
[Fact]
public void IntroductionTokenBindsAttemptAndRedactsTheFixedTicket()
{
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000301"));
string authenticator = Credential('T');
string encoded = NatIntroductionTokenCodec.Encode(attemptId, authenticator);
Assert.Equal(NatIntroductionTokenCodec.EncodedLength, encoded.Length);
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
Assert.True(NatIntroductionTokenCodec.TryDecode(encoded, out NatIntroductionToken? decoded));
Assert.NotNull(decoded);
Assert.Equal(attemptId, decoded.AttemptId);
Assert.Equal(encoded, decoded.ConnectionTicket);
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
}
[Fact]
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
{
string valid = NatIntroductionTokenCodec.Encode(
new JoinAttemptId(Guid.Parse("abcdef00-0000-0000-0000-000000000302")),
Credential('T'));
Assert.False(NatIntroductionTokenCodec.TryDecode(null, out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1], out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1] + "!", out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(new string('A', 43), out _));
}
[Fact]
public void DirectConnectionRequestRoundTripsFixedBoundedPayloadAndRedactsTicket()
{
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000303"));
string ticket = Credential('D');
byte[] encoded = DirectConnectionRequestCodec.Encode(attemptId, ticket);
Assert.Equal(DirectConnectionRequestCodec.EncodedLength, encoded.Length);
Assert.True(DirectConnectionRequestCodec.TryDecode(encoded, out DirectConnectionRequest? decoded));
Assert.NotNull(decoded);
Assert.Equal(attemptId, decoded.AttemptId);
Assert.Equal(ticket, decoded.ConnectionTicket);
Assert.DoesNotContain(ticket, decoded.ToString(), StringComparison.Ordinal);
encoded[0] ^= 0xff;
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded, out _));
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded.AsSpan(1), out _));
}
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
}
@@ -2,7 +2,9 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
@@ -91,14 +93,149 @@ public sealed class JoinAttemptHttpEndpointTests
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest); using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode); Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
using HttpRequestMessage emptyPollRequest = new( using HttpRequestMessage cancelledPollRequest = new(
HttpMethod.Get, HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10"); $"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken); cancelledPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest); using HttpResponseMessage cancelledPollResponse = await host.HttpClient.SendAsync(cancelledPollRequest);
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>( BrowseHostJoinAttemptsResponse cancelledPoll = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options)); await cancelledPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
Assert.Empty(empty.Items); HostJoinAttempt cancelledAttempt = Assert.Single(cancelledPoll.Items);
Assert.Equal(created.AttemptId, cancelledAttempt.AttemptId);
Assert.True(cancelledAttempt.IsCancelled);
}
[Fact]
public async Task OutcomeReportingIsCapabilityAuthenticatedAndIdempotentOverHttp()
{
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(),
host.PublisherCredential));
Assert.True(host.Capabilities.TryFingerprint(
session.HostPresenceCapability,
out SecretFingerprint presenceFingerprint));
Assert.True(host.Store.BindHostPresence(new(
session.HostPresenceHandle,
presenceFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
null)).Succeeded);
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
"v1/join-attempts",
new CreateJoinAttemptRequest
{
IdempotencyKey = "outcome-report-1",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = session.ListingId,
ProtocolVersion = 7,
},
ContractJson.Options);
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
ReportConnectionOutcomeRequest report = new()
{
Outcome = ConnectionOutcomeKind.PunchTimedOut,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
};
ReportConnectionOutcomeResponse first = await SendOutcomeAsync(
host.HttpClient,
created,
report);
ReportConnectionOutcomeResponse duplicate = await SendOutcomeAsync(
host.HttpClient,
created,
report);
Assert.True(first.Accepted);
Assert.False(first.IsDuplicate);
Assert.True(duplicate.Accepted);
Assert.True(duplicate.IsDuplicate);
Assert.Equal(
1,
host.OutcomeMetrics.GetCount(
ConnectionOutcomeKind.PunchTimedOut,
ConnectionElapsedBucket.FiveToFifteenSeconds));
using HttpRequestMessage conflictRequest = OutcomeRequest(
created,
new ReportConnectionOutcomeRequest
{
Outcome = ConnectionOutcomeKind.Connected,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
});
using HttpResponseMessage conflict = await host.HttpClient.SendAsync(conflictRequest);
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
using HttpRequestMessage unauthorizedRequest = OutcomeRequest(created, report);
unauthorizedRequest.Headers.Remove("X-Rendezvous-Client-Punch-Capability");
unauthorizedRequest.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
new string('X', ContractLimits.DerivedCredentialCharacters));
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedRequest);
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
}
[Theory]
[InlineData(7u, HttpStatusCode.Gone, RendezvousErrorCode.StaleHost)]
[InlineData(8u, HttpStatusCode.Conflict, RendezvousErrorCode.IncompatibleProtocol)]
public async Task JoinCreationPreservesTypedTerminalErrorsOverHttp(
uint protocolVersion,
HttpStatusCode expectedStatus,
RendezvousErrorCode expectedError)
{
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(),
host.PublisherCredential));
using HttpResponseMessage response = await host.HttpClient.PostAsJsonAsync(
"v1/join-attempts",
new CreateJoinAttemptRequest
{
IdempotencyKey = $"typed-http-error-{protocolVersion}",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = session.ListingId,
ProtocolVersion = protocolVersion,
},
ContractJson.Options);
Assert.Equal(expectedStatus, response.StatusCode);
ApiError error = Assert.IsType<ApiError>(
await response.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
Assert.Equal(expectedError, error.Code);
}
private static async Task<ReportConnectionOutcomeResponse> SendOutcomeAsync(
HttpClient client,
CreateJoinAttemptResponse attempt,
ReportConnectionOutcomeRequest report)
{
using HttpRequestMessage request = OutcomeRequest(attempt, report);
using HttpResponseMessage response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return Assert.IsType<ReportConnectionOutcomeResponse>(
await response.Content.ReadFromJsonAsync<ReportConnectionOutcomeResponse>(ContractJson.Options));
}
private static HttpRequestMessage OutcomeRequest(
CreateJoinAttemptResponse attempt,
ReportConnectionOutcomeRequest report)
{
HttpRequestMessage request = new(
HttpMethod.Post,
$"v1/join-attempts/{attempt.AttemptId}/outcome")
{
Content = JsonContent.Create(report, options: ContractJson.Options),
};
request.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
attempt.ClientPunchCapability);
return request;
} }
private static T AssertSuccess<T>(RendezvousClientResult<T> result) private static T AssertSuccess<T>(RendezvousClientResult<T> result)
@@ -130,18 +267,21 @@ public sealed class JoinAttemptHttpEndpointTests
HttpClient httpClient, HttpClient httpClient,
InMemoryEphemeralRendezvousStore store, InMemoryEphemeralRendezvousStore store,
EphemeralCapabilityIssuer capabilities, EphemeralCapabilityIssuer capabilities,
ConnectionOutcomeMetrics outcomeMetrics,
string publisherCredential) string publisherCredential)
{ {
_application = application; _application = application;
HttpClient = httpClient; HttpClient = httpClient;
Store = store; Store = store;
Capabilities = capabilities; Capabilities = capabilities;
OutcomeMetrics = outcomeMetrics;
PublisherCredential = publisherCredential; PublisherCredential = publisherCredential;
} }
internal HttpClient HttpClient { get; } internal HttpClient HttpClient { get; }
internal InMemoryEphemeralRendezvousStore Store { get; } internal InMemoryEphemeralRendezvousStore Store { get; }
internal EphemeralCapabilityIssuer Capabilities { get; } internal EphemeralCapabilityIssuer Capabilities { get; }
internal ConnectionOutcomeMetrics OutcomeMetrics { get; }
internal string PublisherCredential { get; } internal string PublisherCredential { get; }
internal static async Task<JoinHttpTestHost> StartAsync() internal static async Task<JoinHttpTestHost> StartAsync()
@@ -165,6 +305,8 @@ public sealed class JoinAttemptHttpEndpointTests
options.ThrowOnBadRequest = true); options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>(); builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddOptions<AbuseProtectionOptions>();
builder.Services.AddSingleton<AbuseProtectionService>();
builder.Services.AddSingleton(provisioning); builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies); builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials); builder.Services.AddSingleton(provisioning.Credentials);
@@ -179,9 +321,13 @@ public sealed class JoinAttemptHttpEndpointTests
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>(); builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>(); builder.Services.AddSingleton<JoinAttemptService>();
ConnectionOutcomeMetrics outcomeMetrics = new();
builder.Services.AddSingleton(outcomeMetrics);
builder.Services.AddSingleton<ConnectionOutcomeService>();
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
await app.StartAsync(); await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>(); IServer server = app.Services.GetRequiredService<IServer>();
@@ -191,6 +337,7 @@ public sealed class JoinAttemptHttpEndpointTests
new HttpClient { BaseAddress = new Uri(address) }, new HttpClient { BaseAddress = new Uri(address) },
store, store,
capabilities, capabilities,
outcomeMetrics,
credential); credential);
} }
@@ -1,6 +1,8 @@
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts; namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
@@ -64,7 +66,7 @@ public sealed class JoinAttemptServiceTests
using JoinAttemptFixture fixture = new(); using JoinAttemptFixture fixture = new();
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false); (RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
Assert.Equal( Assert.Equal(
RendezvousErrorCode.NotFound, RendezvousErrorCode.StaleHost,
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error); fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
(RegisterSessionResponse active, _) = fixture.CreateHost(); (RegisterSessionResponse active, _) = fixture.CreateHost();
@@ -81,6 +83,41 @@ public sealed class JoinAttemptServiceTests
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error); fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
} }
[Fact]
public void ListingProtocolMismatchRemainsDistinctWhenTheRequestedProtocolIsAllowed()
{
GamePolicyOptions policy = ProvisioningTestData.CreatePolicy();
policy.ProtocolVersions.Add(8);
using JoinAttemptFixture fixture = new(joinPolicy: policy);
(RegisterSessionResponse active, _) = fixture.CreateHost();
CreateJoinAttemptRequest request = fixture.Request(active.ListingId);
request.ProtocolVersion = 8;
Assert.Equal(
RendezvousErrorCode.IncompatibleProtocol,
fixture.Service.Create(fixture.ClientSubject, request).Error);
}
[Fact]
public void IssuedAttemptCarriesTheHostsDedicatedFallbackCandidate()
{
using JoinAttemptFixture fixture = new();
RegisterSessionRequest registrationRequest = fixture.Sessions.Request();
registrationRequest.DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.91",
Port = 9_061,
};
RegisterSessionResponse registration = fixture.Sessions.Register(registrationRequest);
Assert.True(fixture.Sessions.BindPresence(registration).Succeeded);
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
Assert.Equal("203.0.113.91", created.DedicatedFallback!.Address);
Assert.Equal(9_061, created.DedicatedFallback.Port);
}
[Fact] [Fact]
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging() public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
{ {
@@ -152,12 +189,47 @@ public sealed class JoinAttemptServiceTests
Assert.True(fixture.Service.Cancel( Assert.True(fixture.Service.Cancel(
created.AttemptId, created.AttemptId,
created.ClientPunchCapability).Succeeded); created.ClientPunchCapability).Succeeded);
Assert.Empty(fixture.Service.BrowseForHost( Assert.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
HostJoinAttempt cancelled = Assert.Single(fixture.Service.BrowseForHost(
registration.ListingId, registration.ListingId,
ContractLimits.ContractVersion, ContractLimits.ContractVersion,
registration.LeaseToken, registration.LeaseToken,
10, 10,
null).Value!.Items); null).Value!.Items);
Assert.Equal(created.AttemptId, cancelled.AttemptId);
Assert.True(cancelled.IsCancelled);
}
[Fact]
public void CancellationAfterIntroductionRevokesTicketIssuanceAndConsumption()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
JoinAttemptServiceResult<ConnectionTicketGrant> issued =
fixture.Service.IssueConnectionTicket(introduction.Attempt);
Assert.True(issued.Succeeded);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
issued.Value!.Ticket,
out SecretFingerprint ticketFingerprint));
Assert.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
StoredJoinAttempt cancelled = fixture.GetAttempt(registration, created.AttemptId);
Assert.True(cancelled.IsCancelled);
Assert.Equal(
RendezvousErrorCode.Conflict,
fixture.Service.IssueConnectionTicket(cancelled).Error);
Assert.Equal(
StoreResultCode.Conflict,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
created.AttemptId,
ticketFingerprint)).Code);
} }
[Fact] [Fact]
@@ -12,11 +12,15 @@ internal sealed class JoinAttemptFixture : IDisposable
{ {
private int _sequence; private int _sequence;
public JoinAttemptFixture(EphemeralStoreOptions? options = null) public JoinAttemptFixture(
EphemeralStoreOptions? options = null,
GamePolicyOptions? joinPolicy = null)
{ {
Sessions = new(options); Sessions = new(options);
Cursors = new(); Cursors = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]); GamePolicyRegistry policies = GamePolicyRegistry.Create([
joinPolicy ?? ProvisioningTestData.CreatePolicy(),
]);
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock); Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40")); ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
} }
@@ -0,0 +1,253 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Net;
using FinalFactory.Rendezvous.Server.Observability;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.State;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Tests.Observability;
[CollectionDefinition(RendezvousTelemetryIsolation.Name, DisableParallelization = true)]
public sealed class RendezvousTelemetryIsolation
{
public const string Name = "Rendezvous telemetry";
}
[Collection(RendezvousTelemetryIsolation.Name)]
public sealed class ObservabilityTests
{
private static readonly HashSet<string> AllowedTagKeys =
[
"operation",
"status_code",
"result",
"transport",
"partition",
"action",
"outcome",
"elapsed_bucket",
];
[Fact]
public void MetricsAndTracesUseBoundedDimensionsWithoutSensitiveValues()
{
EphemeralStateFixture fixture = new();
using RendezvousTelemetry telemetry = new(fixture.Store);
List<Measurement> measurements = [];
using MeterListener meterListener = new();
meterListener.InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == RendezvousTelemetry.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
};
meterListener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
measurements.Add(new(instrument.Name, value, Tags(tags))));
meterListener.SetMeasurementEventCallback<int>((instrument, value, tags, _) =>
measurements.Add(new(instrument.Name, value, Tags(tags))));
meterListener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
measurements.Add(new(instrument.Name, value, Tags(tags))));
meterListener.Start();
Activity? observed = null;
List<string> activityData = [];
using ActivityListener activityListener = new()
{
ShouldListenTo = source => source.Name == RendezvousTelemetry.ActivitySourceName,
Sample = static (ref ActivityCreationOptions<ActivityContext> _) =>
ActivitySamplingResult.AllData,
ActivityStopped = activity =>
{
observed = activity;
activityData.Add(activity.DisplayName);
activityData.AddRange(activity.TagObjects.Select(static tag => $"{tag.Key}={tag.Value}"));
},
};
ActivitySource.AddActivityListener(activityListener);
const string secret = "secret-player-token-canary";
using (telemetry.StartActivity("HTTP GetOperatorStatus", ActivityKind.Server))
{
telemetry.RecordHttp("GetOperatorStatus", 200, 3.5);
telemetry.RecordUdp("frozen", "Introduced", 1.25);
telemetry.RecordLimiterDrop("udp", "rate-or-concurrency");
telemetry.RecordAudit("revoke-listing", "succeeded");
telemetry.RecordConnectionOutcome("Connected", "UnderOneSecond");
telemetry.RecordOperatorAuthentication("accepted");
telemetry.RecordPairingLatency(12.5);
}
NatMediationProcessor processor = new(null!, null!, null!, telemetry: telemetry);
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessRequest(
new IPEndPoint(IPAddress.Parse("10.0.0.8"), 9000),
new IPEndPoint(IPAddress.Parse("203.0.113.8"), 50000),
secret,
NoopIntroductionSink.Instance));
meterListener.RecordObservableInstruments();
Assert.NotNull(observed);
string flattened = string.Join('|', measurements.Select(static item => item.ToString()));
Assert.DoesNotContain(secret, flattened, StringComparison.Ordinal);
Assert.DoesNotContain(secret, string.Join('|', activityData), StringComparison.Ordinal);
Assert.Contains(measurements, static item => item.Name == "rendezvous.http.requests");
Assert.Contains(measurements, static item => item.Name == "rendezvous.udp.results");
Assert.Contains(measurements, static item => item.Name == "rendezvous.limiter.drops");
Assert.Contains(measurements, static item => item.Name == "rendezvous.queue.depth");
Assert.Contains(measurements, static item => item.Name == "rendezvous.store.active_leases");
Assert.Contains(measurements, static item => item.Name == "rendezvous.store.expiry_churn");
Assert.Contains(measurements, static item => item.Name == "rendezvous.pairing.latency");
Assert.All(measurements.SelectMany(static item => item.Tags), static tag =>
Assert.Contains(tag.Key, AllowedTagKeys));
}
[Fact]
public void MetricScrapeExpiresIdleStateAndReportsExpiryChurn()
{
EphemeralStateFixture fixture = new();
Assert.True(fixture.Store.CreateListing(fixture.ListingCommand()).Succeeded);
using RendezvousTelemetry telemetry = new(fixture.Store);
List<Measurement> measurements = [];
using MeterListener listener = new();
listener.InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name == RendezvousTelemetry.MeterName)
{
meterListener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
measurements.Add(new(instrument.Name, value, Tags(tags))));
listener.SetMeasurementEventCallback<int>((instrument, value, tags, _) =>
measurements.Add(new(instrument.Name, value, Tags(tags))));
listener.Start();
listener.RecordObservableInstruments();
Assert.Equal(
1,
Assert.Single(measurements, static item => item.Name == "rendezvous.store.active_leases").Value);
fixture.Clock.Advance(TimeSpan.FromSeconds(61));
measurements.Clear();
listener.RecordObservableInstruments();
Assert.Equal(
0,
Assert.Single(measurements, static item => item.Name == "rendezvous.store.active_leases").Value);
Assert.True(Assert.Single(
measurements,
static item => item.Name == "rendezvous.store.expiry_churn").Value >= 1);
}
[Fact]
public void AuditTrailFingerprintsIdentifiersEnforcesRetentionAndBoundsCapacity()
{
EphemeralStateFixture fixture = new();
using RendezvousTelemetry telemetry = new(fixture.Store);
CapturingLogger<AuditTrail> logger = new();
ManualTimeProvider time = new(new DateTimeOffset(2026, 7, 16, 0, 0, 0, TimeSpan.Zero));
AuditTrail audit = new(
Options.Create(new AuditOptions { MaxEntries = 100, RetentionDays = 1 }),
logger,
telemetry,
time);
const string actor = "operator-secret-subject";
const string target = "player-secret-subject";
for (int index = 0; index < 101; index++)
{
audit.Record(actor, "revoke-principal", "succeeded", "principal", target, "safe-correlation");
}
IReadOnlyList<AuditEntry> bounded = audit.GetEntriesForTests();
Assert.Equal(100, bounded.Count);
Assert.All(bounded, entry =>
{
Assert.DoesNotContain(actor, entry.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain(target, entry.ToString(), StringComparison.Ordinal);
Assert.NotEqual(actor, entry.ActorFingerprint);
Assert.NotEqual(target, entry.TargetFingerprint);
});
Assert.DoesNotContain(actor, string.Join('|', logger.Messages), StringComparison.Ordinal);
Assert.DoesNotContain(target, string.Join('|', logger.Messages), StringComparison.Ordinal);
time.Advance(TimeSpan.FromDays(2));
Assert.Empty(audit.GetEntriesForTests());
Assert.Empty(audit.GetAggregateCounts());
audit.Record(actor, "inspect-status", "succeeded", "service", "rendezvous", "safe-correlation");
Assert.Single(audit.GetEntriesForTests());
Assert.Equal(1, audit.GetAggregateCounts()["inspect-status:succeeded"]);
}
private static KeyValuePair<string, object?>[] Tags(
ReadOnlySpan<KeyValuePair<string, object?>> tags) => tags.ToArray();
private sealed record Measurement(
string Name,
double Value,
KeyValuePair<string, object?>[] Tags);
private sealed class ManualTimeProvider(DateTimeOffset now) : TimeProvider
{
private DateTimeOffset _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now += duration;
}
private sealed class NoopIntroductionSink : INatIntroductionSink
{
public static NoopIntroductionSink Instance { get; } = new();
public void Introduce(NatIntroductionPlan plan)
{
}
}
}
internal sealed class CapturingLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter) => Messages.Add(formatter(state, exception));
}
internal sealed class CapturingLoggerProvider : ILoggerProvider
{
public ConcurrentQueue<string> Messages { get; } = new();
public ILogger CreateLogger(string categoryName) => new Sink(Messages);
public void Dispose() => GC.SuppressFinalize(this);
private sealed class Sink(ConcurrentQueue<string> messages) : ILogger
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => Scope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter) => messages.Enqueue(formatter(state, exception));
}
private sealed class Scope : IDisposable
{
public static Scope Instance { get; } = new();
public void Dispose()
{
}
}
}
@@ -0,0 +1,519 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Observability;
using FinalFactory.Rendezvous.Server.Operations;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.Observability;
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.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace FinalFactory.Rendezvous.Tests.Operations;
[Collection(RendezvousTelemetryIsolation.Name)]
public sealed class OperatorEndpointTests
{
[Fact]
public async Task OperatorSurfaceSeparatesAuthenticationConfirmsActionsAndRedactsInspection()
{
await using OperatorTestHost host = await OperatorTestHost.StartAsync();
List<string> telemetryData = [];
using MeterListener meterListener = new();
meterListener.InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == RendezvousTelemetry.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
};
meterListener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
CaptureMeasurement(telemetryData, instrument, value, tags));
meterListener.SetMeasurementEventCallback<int>((instrument, value, tags, _) =>
CaptureMeasurement(telemetryData, instrument, value, tags));
meterListener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
CaptureMeasurement(telemetryData, instrument, value, tags));
meterListener.Start();
using ActivityListener activityListener = new()
{
ShouldListenTo = static source => source.Name == RendezvousTelemetry.ActivitySourceName,
Sample = static (ref ActivityCreationOptions<ActivityContext> _) =>
ActivitySamplingResult.AllData,
ActivityStopped = activity =>
{
telemetryData.Add(activity.DisplayName);
telemetryData.AddRange(activity.TagObjects.Select(static tag => $"{tag.Key}={tag.Value}"));
},
};
ActivitySource.AddActivityListener(activityListener);
using HttpResponseMessage liveBeforeDependencies = await host.Client.GetAsync("/health/live");
Assert.Equal(HttpStatusCode.OK, liveBeforeDependencies.StatusCode);
using HttpResponseMessage readyBeforeUdp = await host.Client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.ServiceUnavailable, readyBeforeUdp.StatusCode);
using HttpResponseMessage unauthenticated = await host.Client.GetAsync("/v1/operator/status");
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
AuthenticationHeaderValue challenge = Assert.Single(
unauthenticated.Headers.WwwAuthenticate);
Assert.Equal("Bearer", challenge.Scheme);
Assert.Equal("realm=\"operator\"", challenge.Parameter);
using HttpResponseMessage publisher = await SendAsync(
host,
HttpMethod.Get,
"/v1/operator/status",
host.PublisherCredential);
Assert.Equal(HttpStatusCode.Unauthorized, publisher.StatusCode);
using HttpResponseMessage status = await SendAsync(
host,
HttpMethod.Get,
"/v1/operator/status",
host.ReadOnlyOperatorCredential);
Assert.Equal(HttpStatusCode.OK, status.StatusCode);
string statusJson = await status.Content.ReadAsStringAsync();
Assert.Contains("not-ready", statusJson, StringComparison.Ordinal);
Assert.DoesNotContain(host.OwnerCanary, statusJson, StringComparison.Ordinal);
Assert.DoesNotContain("203.0.113.25", statusJson, StringComparison.Ordinal);
Assert.DoesNotContain("metadata", statusJson, StringComparison.OrdinalIgnoreCase);
OperatorStatusResponse? operatorStatus = JsonSerializer.Deserialize<OperatorStatusResponse>(
statusJson,
ContractJson.Options);
Assert.Contains(operatorStatus!.Tenants, static tenant =>
tenant.GameId == "space-game"
&& tenant.EnvironmentId == "production"
&& tenant.Status == "enabled");
Assert.Contains(operatorStatus.SigningKeys, static key =>
key.KeyId == OperatorTestHost.OperatorKeyId
&& key.Status == "signing"
&& key.CredentialKinds.SequenceEqual(["Operator"]));
await host.StartUdpAsync();
using HttpResponseMessage readyAfterUdp = await host.Client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.OK, readyAfterUdp.StatusCode);
using HttpResponseMessage exception = await SendAsync(
host,
HttpMethod.Post,
"/test/exception",
host.FullOperatorCredential);
Assert.Equal(HttpStatusCode.InternalServerError, exception.StatusCode);
using HttpResponseMessage saturatedPublic = await host.Client.GetAsync("/test/public");
Assert.Equal(HttpStatusCode.TooManyRequests, saturatedPublic.StatusCode);
using HttpResponseMessage forbidden = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/drain",
host.ReadOnlyOperatorCredential,
new BeginDrainRequest { Confirmation = "DRAIN" });
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
SessionListingId listingId = host.CreateListing(host.OwnerCanary);
using HttpResponseMessage unconfirmedListing = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/listings/revoke",
host.FullOperatorCredential,
new RevokeListingRequest
{
ListingId = listingId.ToString(),
ConfirmListingId = Guid.NewGuid().ToString("D"),
});
Assert.Equal(HttpStatusCode.BadRequest, unconfirmedListing.StatusCode);
Assert.True(host.Store.GetListing(listingId, false).Succeeded);
using HttpResponseMessage revokedListing = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/listings/revoke",
host.FullOperatorCredential,
new RevokeListingRequest
{
ListingId = listingId.ToString(),
ConfirmListingId = listingId.ToString(),
});
Assert.Equal(HttpStatusCode.OK, revokedListing.StatusCode);
Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(listingId, false).Code);
const string principalCanary = "publisher-player-canary";
SessionListingId principalListing = host.CreateListing(principalCanary);
using HttpResponseMessage unconfirmedPrincipal = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/principals/revoke",
host.FullOperatorCredential,
new RevokePrincipalRequest
{
Subject = principalCanary,
ConfirmSubject = "different-subject",
LifetimeSeconds = 60,
});
Assert.Equal(HttpStatusCode.BadRequest, unconfirmedPrincipal.StatusCode);
Assert.True(host.Store.GetListing(principalListing, false).Succeeded);
using HttpResponseMessage revokedPrincipal = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/principals/revoke",
host.FullOperatorCredential,
new RevokePrincipalRequest
{
Subject = principalCanary,
ConfirmSubject = principalCanary,
LifetimeSeconds = 60,
});
Assert.Equal(HttpStatusCode.OK, revokedPrincipal.StatusCode);
OperatorActionResponse? principalResult = await revokedPrincipal.Content
.ReadFromJsonAsync<OperatorActionResponse>(ContractJson.Options);
Assert.Equal(1, principalResult!.AffectedResources);
Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(principalListing, false).Code);
StoreResult<StoredListing> blockedPublisher = host.CreateListingResult(
principalCanary,
out _);
Assert.Equal(StoreResultCode.Revoked, blockedPublisher.Code);
using HttpResponseMessage drain = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/drain",
host.FullOperatorCredential,
new BeginDrainRequest { Confirmation = "DRAIN" });
Assert.Equal(HttpStatusCode.OK, drain.StatusCode);
Assert.True(host.Store.IsDraining);
using HttpResponseMessage liveDuringDrain = await host.Client.GetAsync("/health/live");
Assert.Equal(HttpStatusCode.OK, liveDuringDrain.StatusCode);
using HttpResponseMessage readyDuringDrain = await host.Client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.ServiceUnavailable, readyDuringDrain.StatusCode);
using HttpResponseMessage firstPublisherKeyRevocation = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/keys/revoke",
host.FullOperatorCredential,
new RevokeSigningKeyRequest { KeyId = "key-1", ConfirmKeyId = "key-1" });
Assert.Equal(HttpStatusCode.OK, firstPublisherKeyRevocation.StatusCode);
using HttpResponseMessage repeatedPublisherKeyRevocation = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/keys/revoke",
host.FullOperatorCredential,
new RevokeSigningKeyRequest { KeyId = "key-1", ConfirmKeyId = "key-1" });
Assert.Equal(HttpStatusCode.OK, repeatedPublisherKeyRevocation.StatusCode);
using HttpResponseMessage unconfirmedKey = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/keys/revoke",
host.FullOperatorCredential,
new RevokeSigningKeyRequest
{
KeyId = OperatorTestHost.OperatorKeyId,
ConfirmKeyId = "different-key",
});
Assert.Equal(HttpStatusCode.BadRequest, unconfirmedKey.StatusCode);
using HttpResponseMessage revokedKey = await SendAsync(
host,
HttpMethod.Post,
"/v1/operator/keys/revoke",
host.FullOperatorCredential,
new RevokeSigningKeyRequest
{
KeyId = OperatorTestHost.OperatorKeyId,
ConfirmKeyId = OperatorTestHost.OperatorKeyId,
});
Assert.Equal(HttpStatusCode.OK, revokedKey.StatusCode);
using HttpResponseMessage afterKeyRevocation = await SendAsync(
host,
HttpMethod.Get,
"/v1/operator/status",
host.FullOperatorCredential);
Assert.Equal(HttpStatusCode.Unauthorized, afterKeyRevocation.StatusCode);
string auditText = string.Join('|', host.Audit.GetEntriesForTests());
string logText = string.Join('|', host.AuditLogger.Messages.Concat(host.AllLogs.Messages));
Assert.DoesNotContain(host.OwnerCanary, auditText, StringComparison.Ordinal);
Assert.DoesNotContain(principalCanary, auditText, StringComparison.Ordinal);
Assert.DoesNotContain(listingId.ToString(), auditText, StringComparison.Ordinal);
Assert.DoesNotContain(host.OwnerCanary, logText, StringComparison.Ordinal);
Assert.DoesNotContain(principalCanary, logText, StringComparison.Ordinal);
Assert.DoesNotContain("exception-secret-canary", logText, StringComparison.Ordinal);
Assert.DoesNotContain(host.FullOperatorCredential, logText, StringComparison.Ordinal);
meterListener.RecordObservableInstruments();
string telemetryText = string.Join('|', telemetryData);
Assert.DoesNotContain(host.OwnerCanary, telemetryText, StringComparison.Ordinal);
Assert.DoesNotContain(principalCanary, telemetryText, StringComparison.Ordinal);
Assert.DoesNotContain("exception-secret-canary", telemetryText, StringComparison.Ordinal);
Assert.DoesNotContain(host.FullOperatorCredential, telemetryText, StringComparison.Ordinal);
Assert.DoesNotContain(listingId.ToString(), telemetryText, StringComparison.Ordinal);
Assert.DoesNotContain("203.0.113.25", telemetryText, StringComparison.Ordinal);
Assert.Contains(host.Audit.GetEntriesForTests(), static entry =>
entry.Action == "begin-drain" && entry.Result == "succeeded");
Assert.Contains(host.Audit.GetEntriesForTests(), static entry =>
entry.Action == "revoke-listing" && entry.Result == "rejected");
Assert.Contains(host.Audit.GetEntriesForTests(), static entry =>
entry.Action == "begin-drain" && entry.Result == "forbidden");
}
private static async Task<HttpResponseMessage> SendAsync(
OperatorTestHost host,
HttpMethod method,
string path,
string bearer,
object? body = null)
{
using HttpRequestMessage request = new(method, path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
if (body is not null)
{
request.Content = JsonContent.Create(body, options: ContractJson.Options);
}
return await host.Client.SendAsync(request);
}
private static void CaptureMeasurement<T>(
List<string> destination,
Instrument instrument,
T value,
ReadOnlySpan<KeyValuePair<string, object?>> tags)
where T : struct
{
destination.Add($"{instrument.Name}={value}");
destination.AddRange(tags.ToArray().Select(static tag => $"{tag.Key}={tag.Value}"));
}
private sealed class OperatorTestHost : IAsyncDisposable
{
private readonly WebApplication _application;
private int _listingSequence;
private bool _udpStarted;
private OperatorTestHost(
WebApplication application,
HttpClient client,
ManualRendezvousClock clock,
InMemoryEphemeralRendezvousStore store,
AuditTrail audit,
CapturingLogger<AuditTrail> auditLogger,
CapturingLoggerProvider allLogs,
string publisherCredential,
string readOnlyOperatorCredential,
string fullOperatorCredential)
{
_application = application;
Client = client;
Clock = clock;
Store = store;
Audit = audit;
AuditLogger = auditLogger;
AllLogs = allLogs;
PublisherCredential = publisherCredential;
ReadOnlyOperatorCredential = readOnlyOperatorCredential;
FullOperatorCredential = fullOperatorCredential;
}
internal const string OperatorKeyId = "operator-key";
internal string OwnerCanary { get; } = "publisher-owner-canary";
internal HttpClient Client { get; }
internal ManualRendezvousClock Clock { get; }
internal InMemoryEphemeralRendezvousStore Store { get; }
internal AuditTrail Audit { get; }
internal CapturingLogger<AuditTrail> AuditLogger { get; }
internal CapturingLoggerProvider AllLogs { get; }
internal string PublisherCredential { get; }
internal string ReadOnlyOperatorCredential { get; }
internal string FullOperatorCredential { get; }
internal static async Task<OperatorTestHost> StartAsync()
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
SigningKeyOptions publisherKey = ProvisioningTestData.CreateKey();
SigningKeyOptions operatorKey = ProvisioningTestData.CreateKey(
OperatorKeyId,
"operator-secret",
credentialKinds: [PrincipalCredentialKind.Operator],
gameId: null,
environmentId: null);
ProvisioningOptions options = ProvisioningTestData.CreateOptions();
options.SigningKeys = [publisherKey, operatorKey];
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
options,
ProvisioningTestData.CreateSecrets("secret-1", "operator-secret"),
clock.UtcNow);
string publisherCredential = provisioning.Credentials.Issue(
ProvisioningTestData.CreateDedicatedPublisher(),
clock.UtcNow);
string readOnlyCredential = provisioning.Credentials.Issue(
new OperatorPrincipal(
"operator-readonly",
clock.UtcNow.AddMinutes(10),
[OperatorPermission.ReadPolicy]),
clock.UtcNow);
string fullCredential = provisioning.Credentials.Issue(
new OperatorPrincipal(
"operator-full",
clock.UtcNow.AddMinutes(10),
Enum.GetValues<OperatorPermission>()),
clock.UtcNow);
CapturingLogger<AuditTrail> auditLogger = new();
CapturingLoggerProvider allLogs = new();
EphemeralCapabilityIssuer capabilities = new();
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(LogLevel.Debug);
builder.Logging.AddProvider(allLogs);
builder.Logging.AddFilter(
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
LogLevel.None);
builder.Services.ConfigureHttpJsonOptions(static json =>
ContractJson.Configure(json.SerializerOptions));
builder.Services.Configure<RouteHandlerOptions>(static route =>
route.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddOptions<AbuseProtectionOptions>().Configure(static abuse =>
{
abuse.OperatorAllowedAddresses = ["127.0.0.1"];
abuse.HttpGlobalRequestsPerWindow = 1;
abuse.HttpOptionalRequestsPerWindow = 1;
abuse.HttpIpPrefixRequestsPerWindow = 1;
abuse.HttpOptionalIpPrefixRequestsPerWindow = 1;
});
builder.Services.AddOptions<AuditOptions>();
builder.Services.AddOptions<UdpMediatorOptions>().Configure(static udp =>
{
udp.ListenAddress = "127.0.0.1";
udp.Port = 0;
});
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
builder.Services.AddSingleton(store);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
builder.Services.AddSingleton<IWallClock>(clock);
builder.Services.AddSingleton<IMonotonicClock>(clock);
builder.Services.AddSingleton(capabilities);
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
builder.Services.AddSingleton<RendezvousTelemetry>();
builder.Services.AddSingleton<AbuseProtectionService>();
builder.Services.AddSingleton<NatMediationProcessor>();
builder.Services.AddSingleton<UdpMediatorService>();
builder.Services.AddSingleton(new ProvisioningReadiness(true));
builder.Services.AddSingleton<RendezvousReadiness>();
builder.Services.AddSingleton<ILogger<AuditTrail>>(auditLogger);
builder.Services.AddSingleton<AuditTrail>();
builder.Services.AddSingleton<OperatorService>();
WebApplication app = builder.Build();
app.UseMiddleware<TelemetryMiddleware>();
app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapOperatorEndpoints();
app.MapRendezvousHealthEndpoints();
app.MapGet("/test/public", static () => Results.Ok()).WithName("TestPublic");
app.MapPost(
"/test/exception",
static IResult () => throw new InvalidOperationException("exception-secret-canary"))
.WithName("TestSecretException");
await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>();
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
return new OperatorTestHost(
app,
new HttpClient { BaseAddress = new Uri(address) },
clock,
store,
app.Services.GetRequiredService<AuditTrail>(),
auditLogger,
allLogs,
publisherCredential,
readOnlyCredential,
fullCredential);
}
internal SessionListingId CreateListing(string owner)
{
StoreResult<StoredListing> result = CreateListingResult(owner, out SessionListingId listingId);
Assert.True(result.Succeeded);
return listingId;
}
internal StoreResult<StoredListing> CreateListingResult(
string owner,
out SessionListingId listingId)
{
int sequence = Interlocked.Increment(ref _listingSequence);
listingId = new(Guid.NewGuid());
StoreResult<StoredListing> result = Store.CreateListing(new(
$"operator-listing-{sequence}",
$"operator-request-{sequence}",
new ListingDefinition
{
ListingId = listingId,
LeaseId = new(Guid.NewGuid()),
Scope = new(new GameId("space-game"), new EnvironmentId("production")),
OwnerSubject = owner,
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = "Operator test listing",
Visibility = ListingVisibility.Public,
TrustMode = PublisherTrustMode.ManagedDedicated,
CurrentPlayers = 1,
MaximumPlayers = 4,
Metadata = new Dictionary<string, string> { ["mode"] = "online-coop" },
LeaseFingerprint = new("lease-fingerprint"),
HostPresenceHandle = new(Guid.NewGuid()),
HostPresenceFingerprint = new("presence-fingerprint"),
CapabilityDerivationSalt = new string('A', 43),
}));
return result;
}
internal async Task StartUdpAsync()
{
await _application.Services.GetRequiredService<UdpMediatorService>()
.StartAsync(CancellationToken.None);
_udpStarted = true;
}
public async ValueTask DisposeAsync()
{
Client.Dispose();
if (_udpStarted)
{
await _application.Services.GetRequiredService<UdpMediatorService>()
.StopAsync(CancellationToken.None);
}
await _application.StopAsync();
await _application.DisposeAsync();
}
}
}
@@ -94,6 +94,7 @@ public sealed class PrincipalCredentialTests
CredentialValidationError.SignatureInvalid, CredentialValidationError.SignatureInvalid,
service.Validate(tampered, ProvisioningTestData.Now).Error); service.Validate(tampered, ProvisioningTestData.Now).Error);
Assert.True(keys.Revoke("key-1")); Assert.True(keys.Revoke("key-1"));
Assert.True(keys.Revoke("key-1"));
Assert.Equal( Assert.Equal(
CredentialValidationError.KeyRevoked, CredentialValidationError.KeyRevoked,
service.Validate(token, ProvisioningTestData.Now).Error); service.Validate(token, ProvisioningTestData.Now).Error);
@@ -0,0 +1,533 @@
using System.Net;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Http;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Tests.Server.Abuse;
public sealed class AbuseProtectionTests
{
[Fact]
public void Ipv4AndIpv6PrefixesShareBudgetsAndRecoverAfterTheWindow()
{
ManualTimeProvider time = new(new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero));
AbuseProtectionOptions options = PermissiveOptions();
options.HttpIpPrefixRequestsPerWindow = 2;
AbuseProtectionService protection = new(Options.Create(options), time);
AssertAccepted(protection, IPAddress.Parse("198.51.100.10"), "BrowseSessions");
AssertAccepted(protection, IPAddress.Parse("198.51.100.200"), "BrowseSessions");
AssertRejected(protection, IPAddress.Parse("198.51.100.99"), "BrowseSessions");
time.Advance(TimeSpan.FromSeconds(1));
AssertAccepted(protection, IPAddress.Parse("198.51.100.99"), "BrowseSessions");
options = PermissiveOptions();
options.HttpIpPrefixRequestsPerWindow = 1;
protection = new(Options.Create(options), time);
AssertAccepted(protection, IPAddress.Parse("2606:4700:1234:5600::1"), "GetSession");
AssertRejected(protection, IPAddress.Parse("2606:4700:1234:56ff::2"), "GetSession");
AssertAccepted(protection, IPAddress.Parse("2606:4700:1234:5700::2"), "GetSession");
}
[Fact]
public void PrincipalConcurrencyIsReleasedAndRejectedCallsDoNotConsumeRate()
{
AbuseProtectionOptions options = PermissiveOptions();
options.HttpPrincipalConcurrency = 1;
options.HttpPrincipalRequestsPerWindow = 2;
AbuseProtectionService protection = new(Options.Create(options));
Assert.True(protection.TryAcquireHttpIdentity(
"RegisterSession", "game/prod", "publisher-1", null, out var first, out _));
Assert.False(protection.TryAcquireHttpIdentity(
"RegisterSession", "game/prod", "publisher-1", null, out _, out _));
first!.Dispose();
Assert.True(protection.TryAcquireHttpIdentity(
"RegisterSession", "game/prod", "publisher-1", null, out var second, out _));
second!.Dispose();
Assert.False(protection.TryAcquireHttpIdentity(
"RegisterSession", "game/prod", "publisher-1", null, out _, out _));
}
[Fact]
public void TrackerCapacityFailsClosedWithoutGrowingAndAWindowResetRecovers()
{
ManualTimeProvider time = new(new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero));
AbuseProtectionOptions options = PermissiveOptions();
options.MaxTrackedKeys = 10;
options.UdpTrackedKeyLimit = 0;
AbuseProtectionService protection = new(Options.Create(options), time);
AssertAccepted(protection, IPAddress.Parse("198.51.100.1"), "GetSession");
AssertRejected(protection, IPAddress.Parse("203.0.113.1"), "GetSession");
Assert.InRange(protection.TrackedKeyCount, 1, options.MaxTrackedKeys);
time.Advance(TimeSpan.FromSeconds(1));
AssertAccepted(protection, IPAddress.Parse("203.0.113.1"), "GetSession");
Assert.InRange(protection.TrackedKeyCount, 1, options.MaxTrackedKeys);
}
[Fact]
public void OptionalTrafficCannotConsumeTheLeaseOperationReserve()
{
AbuseProtectionOptions options = PermissiveOptions();
options.HttpGlobalRequestsPerWindow = 3;
options.HttpOptionalRequestsPerWindow = 2;
options.HttpIpPrefixRequestsPerWindow = 3;
options.HttpOptionalIpPrefixRequestsPerWindow = 2;
AbuseProtectionService protection = new(Options.Create(options));
IPAddress source = IPAddress.Parse("198.51.100.10");
Assert.True(protection.IsOperatorSourceAllowed(source));
Assert.True(protection.IsOperatorSourceAllowed(IPAddress.Parse("::ffff:198.51.100.10")));
Assert.False(protection.IsOperatorSourceAllowed(IPAddress.Parse("198.51.100.11")));
AssertAccepted(protection, source, "BrowseSessions");
AssertAccepted(protection, source, "BrowseSessions");
AssertRejected(protection, source, "BrowseSessions");
AssertAccepted(protection, source, "RenewSessionLease");
AssertRejected(protection, source, "RenewSessionLease");
}
[Fact]
public void PublicSaturationCannotConsumeTheOperatorPartition()
{
AbuseProtectionOptions options = PermissiveOptions();
options.HttpGlobalRequestsPerWindow = 1;
options.HttpOptionalRequestsPerWindow = 1;
options.OperatorGlobalRequestsPerWindow = 1;
AbuseProtectionService protection = new(Options.Create(options));
IPAddress source = IPAddress.Parse("198.51.100.10");
AssertAccepted(protection, source, "BrowseSessions");
AssertRejected(protection, source, "BrowseSessions");
Assert.True(protection.TryAcquireOperatorIngress(source, out var lease, out _));
lease!.Dispose();
Assert.False(protection.TryAcquireOperatorIngress(source, out _, out _));
}
[Fact]
public async Task DeniedOperatorSourcesConsumeTheBoundedPublicPartition()
{
AbuseProtectionOptions options = PermissiveOptions();
options.OperatorAllowedAddresses = ["192.0.2.10"];
options.HttpGlobalRequestsPerWindow = 1;
options.HttpOptionalRequestsPerWindow = 1;
options.HttpIpPrefixRequestsPerWindow = 1;
options.HttpOptionalIpPrefixRequestsPerWindow = 1;
AbuseProtectionService protection = new(Options.Create(options));
bool dispatched = false;
HttpAbuseProtectionMiddleware middleware = new(
_ =>
{
dispatched = true;
return Task.CompletedTask;
},
protection);
DefaultHttpContext first = Context("198.51.100.10");
first.SetEndpoint(new Endpoint(
_ => Task.CompletedTask,
new EndpointMetadataCollection(new EndpointNameMetadata("GetOperatorStatus")),
"operator-status"));
await middleware.InvokeAsync(first);
Assert.Equal(StatusCodes.Status404NotFound, first.Response.StatusCode);
DefaultHttpContext repeated = Context("198.51.100.10");
repeated.SetEndpoint(first.GetEndpoint());
await middleware.InvokeAsync(repeated);
Assert.Equal(StatusCodes.Status429TooManyRequests, repeated.Response.StatusCode);
Assert.False(dispatched);
}
[Fact]
public void ResourceBudgetsRemainIsolatedAcrossTenantAndPrincipalScopes()
{
AbuseProtectionOptions options = PermissiveOptions();
options.HttpResourceRequestsPerWindow = 1;
AbuseProtectionService protection = new(Options.Create(options));
Assert.True(protection.TryAcquireHttpIdentity(
"UpdateSession", IPAddress.Parse("198.51.100.10"),
"game-a/prod", "publisher", "listing", out var first, out _));
first!.Dispose();
Assert.False(protection.TryAcquireHttpIdentity(
"UpdateSession", IPAddress.Parse("198.51.100.10"),
"game-a/prod", "publisher", "listing", out _, out _));
Assert.True(protection.TryAcquireHttpIdentity(
"UpdateSession", IPAddress.Parse("203.0.113.10"),
"game-b/prod", "publisher", "listing", out var second, out _));
second!.Dispose();
Assert.True(protection.TryAcquireHttpIdentity(
"UpdateSession", IPAddress.Parse("192.0.2.10"),
"game-a/prod", "other-publisher", "listing", out var third, out _));
third!.Dispose();
}
[Fact]
public void RotatingCredentialsCannotBypassIndependentResourceBudgets()
{
AbuseProtectionOptions options = PermissiveOptions();
options.HttpResourceRequestsPerWindow = 2;
options.UdpResourceDatagramsPerWindow = 2;
AbuseProtectionService protection = new(Options.Create(options));
for (int index = 1; index <= 2; index++)
{
Assert.True(protection.TryAcquireHttpIdentity(
"CancelJoinAttempt", null, $"capability-{index}", "attempt", out var lease, out _));
lease!.Dispose();
Assert.True(protection.TryAcceptUdpIdentity(
"Client", $"capability-{index}", "mediation-handle"));
}
Assert.False(protection.TryAcquireHttpIdentity(
"CancelJoinAttempt", null, "capability-3", "attempt", out _, out _));
Assert.False(protection.TryAcceptUdpIdentity(
"Client", "capability-3", "mediation-handle"));
}
[Fact]
public void UdpWireOperationsHaveIndependentBoundedIngressBudgets()
{
AbuseProtectionOptions options = PermissiveOptions();
options.UdpOperationDatagramsPerWindow = 1;
AbuseProtectionService protection = new(Options.Create(options));
IPAddress source = IPAddress.Parse("198.51.100.10");
Assert.True(protection.TryAcceptUdpIngress(source, "frozen"));
Assert.False(protection.TryAcceptUdpIngress(source, "frozen"));
Assert.True(protection.TryAcceptUdpIngress(source, "litenet-or-invalid"));
Assert.False(protection.TryAcceptUdpIngress(source, "litenet-or-invalid"));
}
[Fact]
public void UdpTrackerExhaustionCannotConsumeTheCriticalHttpKeyReserve()
{
AbuseProtectionOptions options = PermissiveOptions();
options.MaxTrackedKeys = 28;
options.CriticalTrackedKeyReserve = 16;
options.UdpTrackedKeyLimit = 12;
AbuseProtectionService protection = new(Options.Create(options));
for (int index = 1; index <= 3; index++)
{
IPAddress address = IPAddress.Parse($"198.51.{index}.1");
_ = protection.TryAcceptUdpIngress(address, "raw");
_ = protection.TryAcceptUdpIdentity("Client", $"capability-{index}", $"resource-{index}");
}
Assert.InRange(protection.TrackedKeyCount, 1, 12);
Assert.True(protection.TryAcquireHttpIngress(
IPAddress.Parse("203.0.113.10"),
"RenewSessionLease",
out var ingress,
out _));
Assert.True(protection.TryAcquireHttpIdentity(
"RenewSessionLease",
"game/prod",
"publisher",
"listing",
out var identity,
out _));
identity!.Dispose();
ingress!.Dispose();
Assert.InRange(protection.TrackedKeyCount, 1, options.MaxTrackedKeys);
}
[Fact]
public async Task HttpOverloadIsTypedAndOversizedBodiesAreRejectedBeforeDispatch()
{
AbuseProtectionOptions options = PermissiveOptions();
options.HttpIpPrefixRequestsPerWindow = 1;
AbuseProtectionService protection = new(Options.Create(options));
int dispatched = 0;
HttpAbuseProtectionMiddleware middleware = new(
_ =>
{
dispatched++;
return Task.CompletedTask;
},
protection);
DefaultHttpContext accepted = Context("198.51.100.10");
await middleware.InvokeAsync(accepted);
Assert.Equal(1, dispatched);
DefaultHttpContext limited = Context("198.51.100.11");
await middleware.InvokeAsync(limited);
Assert.Equal(StatusCodes.Status429TooManyRequests, limited.Response.StatusCode);
Assert.Equal("1", limited.Response.Headers.RetryAfter);
limited.Response.Body.Position = 0;
ApiError? error = await JsonSerializer.DeserializeAsync<ApiError>(
limited.Response.Body,
ContractJson.Options);
Assert.Equal(RendezvousErrorCode.RateLimited, error?.Code);
Assert.Equal(1, error?.RetryAfterSeconds);
Assert.Equal(1, dispatched);
DefaultHttpContext oversized = Context("203.0.113.1");
oversized.Request.ContentLength = ContractLimits.HttpRequestMaxBytes + 1;
await middleware.InvokeAsync(oversized);
Assert.Equal(StatusCodes.Status413PayloadTooLarge, oversized.Response.StatusCode);
Assert.Equal(1, dispatched);
DefaultHttpContext repeatedOversized = Context("203.0.113.2");
repeatedOversized.Request.ContentLength = ContractLimits.HttpRequestMaxBytes + 1;
await middleware.InvokeAsync(repeatedOversized);
Assert.Equal(StatusCodes.Status429TooManyRequests, repeatedOversized.Response.StatusCode);
Assert.Equal(1, dispatched);
}
[Fact]
public void DeterministicHostileUdpCorpusNeverThrowsOrAcceptsOversizedDatagrams()
{
const int seed = 0x15_2026;
Random random = new(seed);
for (int iteration = 0; iteration < 10_000; iteration++)
{
int length = random.Next(0, ContractLimits.UdpDatagramMaxBytes + 257);
byte[] payload = new byte[length];
random.NextBytes(payload);
bool decoded = RendezvousUdpCodec.TryDecode(
payload,
out PresenceDatagram? datagram,
out UdpDecodeError error);
if (length > ContractLimits.UdpDatagramMaxBytes)
{
Assert.False(decoded);
Assert.Null(datagram);
Assert.Equal(UdpDecodeError.DatagramTooLarge, error);
}
}
}
[Fact]
public void ConcurrentAbusiveBurstStaysBoundedAndCannotBlockCriticalHttp()
{
AbuseProtectionOptions options = PermissiveOptions();
options.MaxTrackedKeys = 2_000;
options.UdpTrackedKeyLimit = 1_000;
options.CriticalTrackedKeyReserve = 100;
options.UdpGlobalDatagramsPerWindow = 100_000;
options.UdpIpPrefixDatagramsPerWindow = 100_000;
options.UdpOperationDatagramsPerWindow = 100_000;
AbuseProtectionService protection = new(Options.Create(options));
IPAddress source = IPAddress.Parse("198.51.100.10");
Parallel.For(0, 20_000, index =>
{
_ = protection.TryAcceptUdpIngress(source, "raw");
_ = protection.TryAcceptUdpIdentity(
"Client",
$"capability-{index}",
$"resource-{index}");
});
Assert.InRange(protection.TrackedKeyCount, 1, options.UdpTrackedKeyLimit);
Assert.True(protection.TryAcquireHttpIngress(
IPAddress.Parse("203.0.113.10"),
"RenewSessionLease",
out var lease,
out _));
lease!.Dispose();
Assert.InRange(protection.TrackedKeyCount, 1, options.MaxTrackedKeys);
}
[Fact]
public void SteadyStateUdpAdmissionHasABoundedAllocationBudget()
{
AbuseProtectionOptions options = PermissiveOptions();
options.UdpGlobalDatagramsPerWindow = 100_000;
options.UdpIpPrefixDatagramsPerWindow = 100_000;
options.UdpOperationDatagramsPerWindow = 100_000;
options.UdpCapabilityDatagramsPerWindow = 100_000;
options.UdpResourceDatagramsPerWindow = 100_000;
AbuseProtectionService protection = new(Options.Create(options));
IPAddress source = IPAddress.Parse("198.51.100.10");
_ = protection.TryAcceptUdpIngress(source, "frozen");
_ = protection.TryAcceptUdpIdentity("Host", source, "capability", "resource");
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 10_000; iteration++)
{
Assert.True(protection.TryAcceptUdpIngress(source, "frozen"));
Assert.True(protection.TryAcceptUdpIdentity(
"Host", source, "capability", "resource"));
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.InRange(allocated, 0, 40_000_000);
}
[Fact]
public void DeterministicHttpAndCredentialParserCorpusHasOnlyTypedRejections()
{
const int seed = 0x15_4A50;
Random random = new(seed);
for (int iteration = 0; iteration < 5_000; iteration++)
{
byte[] bytes = new byte[random.Next(0, 1_025)];
random.NextBytes(bytes);
try
{
_ = JsonSerializer.Deserialize<RegisterSessionRequest>(bytes, ContractJson.Options);
}
catch (JsonException)
{
}
string token = Convert.ToBase64String(bytes);
Assert.False(NatPunchRequestTokenCodec.TryDecode(token, out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(token, out _));
}
}
[Fact]
public void SecretFingerprintsAreStableBoundedAndDoNotContainHostileInput()
{
const string hostile = "<script>steal('token')</script>\r\nAuthorization: secret";
string fingerprint = AbuseProtectionService.FingerprintSecret(hostile);
Assert.Equal(fingerprint, AbuseProtectionService.FingerprintSecret(hostile));
Assert.Equal(24, fingerprint.Length);
Assert.DoesNotContain("script", fingerprint, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("secret", fingerprint, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ExceptionResponsesPreservePayloadStatusWithoutEchoingHostileDetails()
{
const string canary = "credential-canary <script> endpoint=203.0.113.8:9000";
DefaultHttpContext context = Context("198.51.100.10");
RendezvousExceptionHandler handler = new(
NullLogger<RendezvousExceptionHandler>.Instance);
Assert.True(await handler.TryHandleAsync(
context,
new BadHttpRequestException(canary, StatusCodes.Status413PayloadTooLarge),
CancellationToken.None));
Assert.Equal(StatusCodes.Status413PayloadTooLarge, context.Response.StatusCode);
context.Response.Body.Position = 0;
using StreamReader reader = new(context.Response.Body);
string body = await reader.ReadToEndAsync();
Assert.DoesNotContain(canary, body, StringComparison.Ordinal);
Assert.DoesNotContain("203.0.113.8", body, StringComparison.Ordinal);
Assert.DoesNotContain("script", body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ForwardedSourcesAreDefaultDenyExactProxyOnlyAndSingleHop()
{
AbuseProtectionOptions disabled = new();
Assert.False(TrustedProxyForwarding.IsEnabled(disabled));
AbuseProtectionOptions enabled = new()
{
TrustedProxyAddresses = ["192.0.2.10"],
};
Assert.True(TrustedProxyForwarding.IsEnabled(enabled));
ForwardedHeadersOptions forwarded = new();
TrustedProxyForwarding.Configure(forwarded, enabled);
ForwardedHeadersMiddleware middleware = new(
_ => Task.CompletedTask,
NullLoggerFactory.Instance,
Options.Create(forwarded));
DefaultHttpContext trusted = Context("192.0.2.10");
trusted.Request.Headers["X-Forwarded-For"] = "198.51.100.7";
await middleware.Invoke(trusted);
Assert.Equal(IPAddress.Parse("198.51.100.7"), trusted.Connection.RemoteIpAddress);
DefaultHttpContext untrusted = Context("192.0.2.11");
untrusted.Request.Headers["X-Forwarded-For"] = "198.51.100.8";
await middleware.Invoke(untrusted);
Assert.Equal(IPAddress.Parse("192.0.2.11"), untrusted.Connection.RemoteIpAddress);
DefaultHttpContext multiHop = Context("192.0.2.10");
multiHop.Request.Headers["X-Forwarded-For"] = "198.51.100.9, 203.0.113.9";
await middleware.Invoke(multiHop);
Assert.Equal(IPAddress.Parse("203.0.113.9"), multiHop.Connection.RemoteIpAddress);
}
private static DefaultHttpContext Context(string address)
{
DefaultHttpContext context = new();
context.Connection.RemoteIpAddress = IPAddress.Parse(address);
context.Response.Body = new MemoryStream();
return context;
}
private static void AssertAccepted(
AbuseProtectionService protection,
IPAddress address,
string operation)
{
Assert.True(protection.TryAcquireHttpIngress(
address, operation, out var lease, out _));
lease!.Dispose();
}
private static void AssertRejected(
AbuseProtectionService protection,
IPAddress address,
string operation) => Assert.False(protection.TryAcquireHttpIngress(
address, operation, out _, out _));
private static AbuseProtectionOptions PermissiveOptions() => new()
{
WindowSeconds = 1,
MaxTrackedKeys = 10_000,
CriticalTrackedKeyReserve = 0,
UdpTrackedKeyLimit = 5_000,
HealthGlobalRequestsPerWindow = 10_000,
HealthGlobalConcurrency = 10_000,
HealthIpPrefixRequestsPerWindow = 10_000,
HealthIpPrefixConcurrency = 1_000,
OperatorAllowedAddresses = ["198.51.100.10"],
OperatorGlobalRequestsPerWindow = 10_000,
OperatorGlobalConcurrency = 10_000,
OperatorIpPrefixRequestsPerWindow = 10_000,
OperatorIpPrefixConcurrency = 1_000,
HttpGlobalRequestsPerWindow = 10_000,
HttpOptionalRequestsPerWindow = 9_000,
HttpIpPrefixRequestsPerWindow = 10_000,
HttpOptionalIpPrefixRequestsPerWindow = 9_000,
HttpOperationRequestsPerWindow = 10_000,
HttpTenantRequestsPerWindow = 10_000,
HttpPrincipalRequestsPerWindow = 10_000,
HttpResourceRequestsPerWindow = 10_000,
HttpGlobalConcurrency = 10_000,
HttpOptionalConcurrency = 9_000,
HttpIpPrefixConcurrency = 10_000,
HttpOptionalIpPrefixConcurrency = 9_000,
HttpOperationConcurrency = 10_000,
HttpTenantConcurrency = 10_000,
HttpPrincipalConcurrency = 10_000,
HttpResourceConcurrency = 10_000,
UdpGlobalDatagramsPerWindow = 10_000,
UdpIpPrefixDatagramsPerWindow = 10_000,
UdpOperationDatagramsPerWindow = 10_000,
UdpCapabilityDatagramsPerWindow = 10_000,
UdpResourceDatagramsPerWindow = 10_000,
};
private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
private DateTimeOffset _utcNow = utcNow;
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan duration) => _utcNow += duration;
}
}
@@ -0,0 +1,457 @@
using System.Collections.Concurrent;
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.JoinAttempts;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Tests.Server;
public sealed class NatMediationProcessorTests
{
[Fact]
public void LimitedAuthenticatedUdpTrafficIsSilentlyDroppedWithoutAnIntroduction()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost(bindPresence: false);
AbuseProtectionOptions options = new()
{
UdpCapabilityDatagramsPerWindow = 1,
UdpResourceDatagramsPerWindow = 10,
};
AbuseProtectionService protection = new(Options.Create(options));
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service,
protection);
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(
NatMediationResult.Dropped,
processor.ProcessRequest(
Endpoint("192.168.1.50", 40_000),
Endpoint("203.0.113.77", 51_234),
token,
sink));
Assert.Empty(sink.Plans);
}
[Fact]
public void AuthenticatedHostPresenceUsesTheObservedGameplaySocket()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost(bindPresence: false);
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
string token = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
registration.HostPresenceHandle,
registration.HostPresenceCapability);
Assert.Equal(
NatMediationResult.HostPresenceAccepted,
processor.ProcessRequest(
Endpoint("192.168.1.50", 40_000),
Endpoint("203.0.113.77", 51_234),
token,
sink));
Assert.Equal(registration.ListingId, Assert.Single(fixture.Sessions.Browse()).Definition.ListingId);
Assert.Empty(sink.Plans);
}
[Fact]
public void MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "same-nat");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
NatIntroductionPlan plan = Assert.Single(sink.Plans);
Assert.Equal(Endpoint("192.168.1.10", 41_000), plan.HostLocal);
Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal);
Assert.Equal(Endpoint("203.0.113.20", 51_000), plan.HostPublic);
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
Assert.True(NatIntroductionTokenCodec.TryDecode(
plan.IntroductionToken,
out NatIntroductionToken? introduction));
Assert.NotNull(introduction);
Assert.Equal(attempt.AttemptId, introduction.AttemptId);
Assert.Equal(43, introduction.ConnectionTicket.Length);
Assert.DoesNotContain(introduction.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
introduction.ConnectionTicket,
out SecretFingerprint ticketFingerprint));
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
attempt.AttemptId,
ticketFingerprint)).Succeeded);
Assert.Equal(
NatMediationResult.Duplicate,
Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
Assert.Single(sink.Plans);
}
[Fact]
public void DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "different-nats");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
_ = Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("8.8.8.8", 42_000), Endpoint("198.51.100.40", 52_000));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
NatIntroductionPlan plan = Assert.Single(sink.Plans);
Assert.Equal(plan.HostPublic, plan.HostLocal);
Assert.Equal(plan.ClientPublic, plan.ClientLocal);
Assert.NotEqual(IPAddress.Parse("8.8.8.8"), plan.ClientLocal.Address);
}
[Fact]
public void RoleAndEndpointSubstitutionAreRejectedWithoutChangingTheFirstBinding()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "substitution");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
string crossRole = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
attempt.Handle,
attempt.ClientCapability);
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessRequest(
Endpoint("192.168.1.10", 41_000),
Endpoint("203.0.113.20", 51_000),
crossRole,
sink));
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
Assert.Equal(
NatMediationResult.Rejected,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.99", 41_999), Endpoint("203.0.113.99", 51_999)));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.2.10", 42_000), Endpoint("198.51.100.40", 52_000)));
Assert.Equal(Endpoint("203.0.113.20", 51_000), Assert.Single(sink.Plans).HostPublic);
}
[Fact]
public void ConcurrentAttemptsForOneSessionNeverCrossWire()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials first = CreateAttempt(fixture, registration, "parallel-1");
AttemptCredentials second = CreateAttempt(fixture, registration, "parallel-2");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
_ = Process(processor, sink, first, NatPunchPeerRole.Host,
Endpoint("10.0.0.10", 41_001), Endpoint("203.0.113.10", 51_001));
_ = Process(processor, sink, second, NatPunchPeerRole.Host,
Endpoint("10.0.0.20", 41_002), Endpoint("203.0.113.20", 51_002));
_ = Process(processor, sink, second, NatPunchPeerRole.Client,
Endpoint("10.0.0.21", 42_002), Endpoint("198.51.100.20", 52_002));
_ = Process(processor, sink, first, NatPunchPeerRole.Client,
Endpoint("10.0.0.11", 42_001), Endpoint("198.51.100.10", 52_001));
Assert.Equal(2, sink.Plans.Count);
Assert.Contains(sink.Plans, plan =>
plan.HostPublic.Equals(Endpoint("203.0.113.10", 51_001))
&& plan.ClientPublic.Equals(Endpoint("198.51.100.10", 52_001)));
Assert.Contains(sink.Plans, plan =>
plan.HostPublic.Equals(Endpoint("203.0.113.20", 51_002))
&& plan.ClientPublic.Equals(Endpoint("198.51.100.20", 52_002)));
}
[Fact]
public async Task ConcurrentDuplicateCompletionEmitsExactlyOneIntroduction()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "completion-race");
NatMediationProcessor processor = CreateProcessor(fixture);
ConcurrentIntroductionSink sink = new();
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
using Barrier barrier = new(2);
Task<NatMediationResult>[] completions = Enumerable.Range(0, 2)
.Select(_ => Task.Run(() =>
{
barrier.SignalAndWait();
return Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000));
}))
.ToArray();
NatMediationResult[] results = await Task.WhenAll(completions);
Assert.Single(results, result => result == NatMediationResult.Introduced);
Assert.Single(sink.Plans);
}
[Fact]
public async Task CancellationAfterIntroductionCreatesAHostRevocationTombstone()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "cancel-race");
NatMediationProcessor processor = CreateProcessor(fixture);
using BlockingIntroductionSink sink = new();
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
Task<NatMediationResult> completion = Task.Run(() => Process(
processor,
sink,
attempt,
NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000),
Endpoint("198.51.100.40", 52_000)));
Assert.True(sink.WaitUntilEntered(TimeSpan.FromSeconds(2)));
JoinAttemptServiceResult<bool> cancelled = fixture.Service.Cancel(
attempt.AttemptId,
attempt.ClientCapability);
Assert.True(cancelled.Succeeded);
sink.Release();
Assert.Equal(NatMediationResult.Introduced, await completion);
HostJoinAttempt cancelledAttempt = Assert.Single(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items);
Assert.True(cancelledAttempt.IsCancelled);
}
[Fact]
public void DuplicateFloodAmortizesGlobalExpiryMaintenance()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "maintenance-budget");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
long before = fixture.Sessions.Store.MaintenanceSweepCount;
for (int index = 0; index < 256; index++)
{
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
}
Assert.InRange(fixture.Sessions.Store.MaintenanceSweepCount - before, 0, 1);
Assert.Empty(sink.Plans);
}
[Fact]
public void MissingStaleCancelledAndMalformedRequestsNeverIntroduce()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials stale = CreateAttempt(fixture, registration, "stale");
AttemptCredentials cancelled = CreateAttempt(fixture, registration, "cancelled");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, stale, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000)));
Assert.True(fixture.Service.Cancel(cancelled.AttemptId, cancelled.ClientCapability).Succeeded);
Assert.Equal(
NatMediationResult.Dropped,
Process(processor, sink, cancelled, NatPunchPeerRole.Client,
Endpoint("192.168.1.12", 42_001), Endpoint("198.51.100.41", 52_001)));
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(21));
Assert.Equal(
NatMediationResult.Dropped,
Process(processor, sink, stale, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessRequest(
Endpoint("192.168.1.10", 41_000),
Endpoint("203.0.113.20", 51_000),
"malformed",
sink));
Assert.Empty(sink.Plans);
}
[Fact]
public void AddressFamiliesMustMatchAndOnlyGlobalIpv6SourcesAreAccepted()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials mismatch = CreateAttempt(fixture, registration, "family-mismatch");
AttemptCredentials ipv6 = CreateAttempt(fixture, registration, "ipv6");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
byte[] shortFrozenIpv6 = RendezvousUdpCodec.Encode(new PresenceDatagram
{
MessageType = UdpPresenceMessageType.ClientPresence,
MediationHandle = ipv6.Handle,
AddressFamily = AddressFamilyKind.Ipv6,
LocalAddress = "fd00::11",
LocalPort = 42_000,
Capability = ipv6.ClientCapability,
});
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessDatagram(
shortFrozenIpv6,
Endpoint("2606:4700:4700::1001", 52_000),
sink));
_ = Process(processor, sink, mismatch, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
Assert.Equal(
NatMediationResult.Rejected,
Process(processor, sink, mismatch, NatPunchPeerRole.Client,
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1111", 52_000)));
Assert.Equal(
NatMediationResult.Dropped,
Process(processor, sink, ipv6, NatPunchPeerRole.Host,
Endpoint("fd00::10", 41_000), Endpoint("2001:db8::10", 51_000)));
_ = Process(processor, sink, ipv6, NatPunchPeerRole.Host,
Endpoint("fd00::10", 41_000), Endpoint("2606:4700:4700::1000", 51_000));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, ipv6, NatPunchPeerRole.Client,
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1001", 52_000)));
Assert.Single(sink.Plans);
}
private static NatMediationProcessor CreateProcessor(JoinAttemptFixture fixture) => new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
private static AttemptCredentials CreateAttempt(
JoinAttemptFixture fixture,
RegisterSessionResponse registration,
string idempotencyKey)
{
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, idempotencyKey);
HostJoinAttempt host = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
return new(
created.AttemptId,
created.MediationHandle,
host.HostPunchCapability,
created.ClientPunchCapability);
}
private static NatMediationResult Process(
NatMediationProcessor processor,
INatIntroductionSink sink,
AttemptCredentials attempt,
NatPunchPeerRole role,
IPEndPoint local,
IPEndPoint observed) => processor.ProcessRequest(
local,
observed,
NatPunchRequestTokenCodec.Encode(
role,
attempt.Handle,
role == NatPunchPeerRole.Client
? attempt.ClientCapability
: attempt.HostCapability),
sink);
private static IPEndPoint Endpoint(string address, int port) =>
new(IPAddress.Parse(address), port);
private sealed record AttemptCredentials(
JoinAttemptId AttemptId,
MediationHandle Handle,
string HostCapability,
string ClientCapability);
private sealed class CaptureIntroductionSink : INatIntroductionSink
{
public List<NatIntroductionPlan> Plans { get; } = [];
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
}
private sealed class ConcurrentIntroductionSink : INatIntroductionSink
{
public ConcurrentBag<NatIntroductionPlan> Plans { get; } = [];
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
}
private sealed class BlockingIntroductionSink : INatIntroductionSink, IDisposable
{
private readonly ManualResetEventSlim _entered = new();
private readonly ManualResetEventSlim _release = new();
public void Introduce(NatIntroductionPlan plan)
{
_entered.Set();
_release.Wait(TimeSpan.FromSeconds(2));
}
public bool WaitUntilEntered(TimeSpan timeout) => _entered.Wait(timeout);
public void Release() => _release.Set();
public void Dispose()
{
_entered.Dispose();
_release.Dispose();
}
}
}
@@ -1,10 +1,10 @@
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.Abuse;
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 +13,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(
ListenAddress = IPAddress.Loopback.ToString(), fixture.Sessions.Store,
Port = 0, fixture.Sessions.Capabilities,
}; fixture.Service);
ManualRendezvousClock clock = new();
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock);
using EphemeralCapabilityIssuer capabilities = new();
using UdpMediatorService service = new( using UdpMediatorService service = new(
Options.Create(options), Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance, NullLogger<UdpMediatorService>.Instance,
store, processor);
capabilities);
await service.StartAsync(timeout.Token); await service.StartAsync(timeout.Token);
@@ -69,9 +38,388 @@ 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);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task NativeLiteNetLibRequestsIntroduceTheAuthorizedPair(bool restartMediator)
{
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);
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);
UdpMediatorService? service = null;
bool serviceStarted = false;
try
{
service = CreateMediator(processor, port: 0);
await service.StartAsync(timeout.Token);
serviceStarted = true;
Assert.True(host.Start(0));
Assert.True(client.Start(0));
if (restartMediator)
{
await AssertNativeIntroductionAsync(
service,
host,
client,
hostTickets,
clientTickets,
created,
hostAttempt,
expectedCount: 1,
cancellationToken: timeout.Token);
created = fixture.Create(registration.ListingId, "native-litenet-after-restart");
hostAttempt = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
int boundPort = Assert.IsType<IPEndPoint>(service.LocalEndpoint).Port;
await service.StopAsync(timeout.Token);
serviceStarted = false;
service.Dispose();
service = null;
service = CreateMediator(processor, boundPort);
await service.StartAsync(timeout.Token);
serviceStarted = true;
Assert.Equal(boundPort, Assert.IsType<IPEndPoint>(service.LocalEndpoint).Port);
}
await AssertNativeIntroductionAsync(
service,
host,
client,
hostTickets,
clientTickets,
created,
hostAttempt,
expectedCount: restartMediator ? 2 : 1,
cancellationToken: timeout.Token);
}
finally
{
host.Stop();
client.Stop();
if (service is not null)
{
try
{
if (serviceStarted)
{
using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5));
await service.StopAsync(cleanup.Token);
}
}
finally
{
service.Dispose();
}
}
}
}
private static async Task AssertNativeIntroductionAsync(
UdpMediatorService service,
NetManager host,
NetManager client,
List<string> hostTickets,
List<string> clientTickets,
CreateJoinAttemptResponse created,
HostJoinAttempt hostAttempt,
int expectedCount,
CancellationToken cancellationToken)
{
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.Distinct(StringComparer.Ordinal).Count() < expectedCount
|| clientTickets.Distinct(StringComparer.Ordinal).Count() < expectedCount)
&& !cancellationToken.IsCancellationRequested)
{
host.PollEvents();
host.NatPunchModule.PollEvents();
client.PollEvents();
client.NatPunchModule.PollEvents();
await Task.Delay(5, cancellationToken);
}
List<string> distinctHostTickets = hostTickets.Distinct(StringComparer.Ordinal).ToList();
List<string> distinctClientTickets = clientTickets.Distinct(StringComparer.Ordinal).ToList();
Assert.Equal(expectedCount, distinctHostTickets.Count);
Assert.Equal(expectedCount, distinctClientTickets.Count);
string hostTicket = distinctHostTickets[^1];
string clientTicket = distinctClientTickets[^1];
Assert.Equal(hostTicket, clientTicket);
Assert.True(NatIntroductionTokenCodec.TryDecode(
hostTicket,
out NatIntroductionToken? introduction));
Assert.NotNull(introduction);
Assert.Equal(created.AttemptId, introduction.AttemptId);
Assert.Equal(43, introduction.ConnectionTicket.Length);
}
private static UdpMediatorService CreateMediator(NatMediationProcessor processor, int port) => new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = port,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
[Fact]
public async Task FrozenV1EnvelopeIsConsumedOnTheLiteNetSocketWithinAmplificationBudget()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "v1-envelope");
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await service.StartAsync(timeout.Token);
using UdpClient host = new(new IPEndPoint(IPAddress.Loopback, 0));
using UdpClient client = new(new IPEndPoint(IPAddress.Loopback, 0));
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
byte[] hostDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
{
MessageType = UdpPresenceMessageType.HostPresence,
MediationHandle = created.MediationHandle,
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.168.1.10",
LocalPort = 41_000,
Capability = hostAttempt.HostPunchCapability,
});
byte[] clientDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
{
MessageType = UdpPresenceMessageType.ClientPresence,
MediationHandle = created.MediationHandle,
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.168.1.11",
LocalPort = 42_000,
Capability = created.ClientPunchCapability,
});
try
{
await host.SendAsync(hostDatagram, mediator, timeout.Token);
await client.SendAsync(clientDatagram, mediator, timeout.Token);
UdpReceiveResult hostIntroduction = await host.ReceiveAsync(timeout.Token);
UdpReceiveResult clientIntroduction = await client.ReceiveAsync(timeout.Token);
Assert.True(
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
<= clientDatagram.Length * 2,
$"The completing authenticated contribution exceeded the 2.0 response-byte budget: "
+ $"responses={hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length}, "
+ $"request={clientDatagram.Length}.");
}
finally
{
await service.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task OversizedMalformedAndGameplayDatagramsReceiveNoResponse()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
AbuseProtectionService protection = new(Options.Create(new AbuseProtectionOptions()));
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service,
protection);
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));
Assert.InRange(protection.TrackedKeyCount, 3, 5);
}
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);
}
}
} }
@@ -4,6 +4,7 @@ using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
@@ -44,6 +45,8 @@ public sealed class SessionHttpEndpointTests
options.ThrowOnBadRequest = true); options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>(); builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddOptions<AbuseProtectionOptions>();
builder.Services.AddSingleton<AbuseProtectionService>();
builder.Services.AddSingleton(provisioning); builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Credentials); builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization); builder.Services.AddSingleton(provisioning.PublisherAuthorization);
@@ -57,6 +60,7 @@ public sealed class SessionHttpEndpointTests
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
await using WebApplication app = builder.Build(); await using WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
await app.StartAsync(); await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>(); IServer server = app.Services.GetRequiredService<IServer>();
@@ -3,6 +3,7 @@ using FinalFactory.Rendezvous.Contracts;
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;
using FinalFactory.Rendezvous.Tests.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Sessions; namespace FinalFactory.Rendezvous.Tests.Sessions;
@@ -118,6 +119,12 @@ public sealed class SessionLeaseServiceTests
["mode"] = "co-op", ["mode"] = "co-op",
["map"] = "europa", ["map"] = "europa",
}, },
DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.92",
Port = 9_062,
},
}); });
StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!; StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!;
@@ -128,6 +135,7 @@ public sealed class SessionLeaseServiceTests
Assert.Equal(fixture.Scope, stored.Definition.Scope); Assert.Equal(fixture.Scope, stored.Definition.Scope);
Assert.Equal(8, stored.Definition.CurrentPlayers); Assert.Equal(8, stored.Definition.CurrentPlayers);
Assert.Equal(8, stored.Definition.MaximumPlayers); Assert.Equal(8, stored.Definition.MaximumPlayers);
Assert.Equal("203.0.113.92", stored.Definition.DedicatedFallback!.Address);
Assert.True(fixture.Service.Delete( Assert.True(fixture.Service.Delete(
fixture.Principal, fixture.Principal,
registration.ListingId, registration.ListingId,
@@ -139,6 +147,45 @@ public sealed class SessionLeaseServiceTests
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code); Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
} }
[Fact]
public void DisabledFallbackPolicyRejectsRegistrationAndUpdateEndpoints()
{
GamePolicyOptions policy = ProvisioningTestData.CreatePolicy();
policy.FallbackPolicy = FallbackPolicyMode.Disabled;
using SessionLeaseFixture fixture = new(policyOptions: policy);
RegisterSessionRequest registrationRequest = fixture.Request();
registrationRequest.DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.94",
Port = 9_064,
};
Assert.Equal(
RendezvousErrorCode.Forbidden,
fixture.Service.Register(fixture.Principal, registrationRequest).Error);
RegisterSessionResponse registration = fixture.Register();
Assert.Equal(
RendezvousErrorCode.Forbidden,
fixture.Service.Update(
fixture.Principal,
registration.ListingId,
new UpdateSessionRequest
{
LeaseToken = registration.LeaseToken,
BuildVersion = "1.4.3",
DisplayName = "Europa Updated",
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
},
DedicatedFallback = registrationRequest.DedicatedFallback,
}).Error);
}
[Fact] [Fact]
public void AnotherPublisherCannotRenewUpdateOrDeleteListing() public void AnotherPublisherCannotRenewUpdateOrDeleteListing()
{ {
@@ -11,13 +11,17 @@ internal sealed class SessionLeaseFixture : IDisposable
{ {
private int _sequence; private int _sequence;
public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null) public SessionLeaseFixture(
EphemeralStoreOptions? storeOptions = null,
GamePolicyOptions? policyOptions = null)
{ {
StoreOptions = storeOptions ?? new EphemeralStoreOptions(); StoreOptions = storeOptions ?? new EphemeralStoreOptions();
Clock = new(); Clock = new();
Store = new(StoreOptions, Clock, Clock); Store = new(StoreOptions, Clock, Clock);
Capabilities = new(); Capabilities = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]); GamePolicyRegistry policies = GamePolicyRegistry.Create([
policyOptions ?? ProvisioningTestData.CreatePolicy(),
]);
Service = new( Service = new(
new PublisherAuthorizationService(policies), new PublisherAuthorizationService(policies),
Store, Store,
@@ -5,6 +5,57 @@ namespace FinalFactory.Rendezvous.Tests.State;
public sealed class InMemoryEphemeralRendezvousStoreTests public sealed class InMemoryEphemeralRendezvousStoreTests
{ {
[Fact]
public void DeterministicHostileStateTransitionsStayTypedAndCapacityBounded()
{
const int seed = 0x15_57A7E;
Random random = new(seed);
EphemeralStateFixture fixture = new(new EphemeralStoreOptions
{
MaxJoinAttempts = 32,
});
StoredListing listing = fixture.CreateVisibleListing(out _);
for (int iteration = 0; iteration < 1_000; iteration++)
{
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
command = random.Next(4) switch
{
0 => command with
{
Scope = new(new GameId("other-game"), new EnvironmentId("test")),
},
1 => command with { ProtocolVersion = command.ProtocolVersion + 1 },
_ => command,
};
StoreResult<StoredJoinAttempt> created = fixture.Store.CreateJoinAttempt(command);
Assert.True(Enum.IsDefined(created.Code));
if (!created.Succeeded || created.Value is null)
{
continue;
}
SecretFingerprint supplied = random.Next(3) == 0
? EphemeralStateFixture.Fingerprint($"wrong-{iteration}")
: created.Value.ClientCapabilityFingerprint;
StoreResult<StoredJoinAttempt> bound = fixture.Store.BindAttemptEndpoint(new(
created.Value.MediationHandle,
AttemptPeerRole.Client,
supplied,
EphemeralStateFixture.OtherPublicEndpoint(20_000 + iteration),
null));
Assert.True(Enum.IsDefined(bound.Code));
}
StoreResult<IReadOnlyList<StoredJoinAttempt>> attempts =
fixture.Store.BrowseHostJoinAttempts(new(
listing.Definition.ListingId,
listing.Definition.LeaseFingerprint,
100));
Assert.True(attempts.Succeeded);
Assert.InRange(attempts.Value!.Count, 1, 32);
}
[Fact] [Fact]
public void IdempotencyRetentionMustCoverResourceLifetimes() public void IdempotencyRetentionMustCoverResourceLifetimes()
{ {
@@ -134,13 +185,13 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!; StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!;
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing); CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt).Code); Assert.Equal(StoreResultCode.StaleHost, fixture.Store.CreateJoinAttempt(attempt).Code);
fixture.Store.BindHostPresence(new( fixture.Store.BindHostPresence(new(
listingCommand.Listing.HostPresenceHandle, listingCommand.Listing.HostPresenceHandle,
listingCommand.Listing.HostPresenceFingerprint, listingCommand.Listing.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(40_000), EphemeralStateFixture.PublicEndpoint(40_000),
null)); null));
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with { ProtocolVersion = 8 }).Code); Assert.Equal(StoreResultCode.IncompatibleProtocol, fixture.Store.CreateJoinAttempt(attempt with { ProtocolVersion = 8 }).Code);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with
{ {
Scope = new(new("other-game"), new("test")), Scope = new(new("other-game"), new("test")),
@@ -385,7 +436,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
StoreResult<int> revoked = fixture.Store.RevokePrincipal(command.Listing.OwnerSubject, TimeSpan.FromMinutes(1)); StoreResult<int> revoked = fixture.Store.RevokePrincipal(command.Listing.OwnerSubject, TimeSpan.FromMinutes(1));
Assert.True(revoked.Succeeded); Assert.True(revoked.Succeeded);
Assert.Equal(2, revoked.Value); Assert.Equal(4, revoked.Value);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code); Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(listing.Definition.ListingId, false).Code);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new( Assert.Equal(StoreResultCode.NotFound, fixture.Store.BindAttemptEndpoint(new(
attempt.MediationHandle, attempt.MediationHandle,
@@ -19,6 +19,8 @@ public sealed class StoreResultMappingTests
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable, [StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected, [StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable, [StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
[StoreResultCode.StaleHost] = RendezvousErrorCode.StaleHost,
[StoreResultCode.IncompatibleProtocol] = RendezvousErrorCode.IncompatibleProtocol,
}; };
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count); Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count);
@@ -0,0 +1,135 @@
using System.Net;
using System.Text;
using FinalFactory.Rendezvous.TestClient;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Tests.TestClient;
public sealed class DirectEchoProtocolTests
{
[Fact]
public async Task HostReleasesPendingNonceWhenPeerDisconnectsBeforeAcknowledgement()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
EventBasedNetListener hostEvents = new();
EventBasedNetListener clientEvents = new();
hostEvents.ConnectionRequestEvent += request => request.Accept();
NetPeer? clientPeer = null;
clientEvents.PeerConnectedEvent += peer => clientPeer = peer;
NetManager hostManager = new(hostEvents);
NetManager clientManager = new(clientEvents);
try
{
Assert.True(hostManager.Start(0));
Assert.True(clientManager.Start(0));
clientManager.Connect(
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
"echo-test");
await PumpUntilAsync(
() => clientPeer is not null,
hostManager,
clientManager,
clientManager,
timeout.Token);
using DirectEchoProtocol host = new(hostEvents, host: true);
clientPeer!.Send(
Encoding.ASCII.GetBytes("rv1-ping:00112233445566778899aabbccddeeff"),
DeliveryMethod.ReliableOrdered);
await PumpUntilAsync(
() => host.PendingHostExchangeCount == 1,
hostManager,
clientManager,
clientManager,
timeout.Token);
clientPeer.Disconnect();
await PumpUntilAsync(
() => host.PendingHostExchangeCount == 0,
hostManager,
clientManager,
clientManager,
timeout.Token);
Assert.Equal(0, host.PendingHostExchangeCount);
}
finally
{
clientManager.Stop();
hostManager.Stop();
}
}
[Fact]
public async Task HostVerifiesOverlappingPeersAgainstTheirOwnNonces()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
EventBasedNetListener hostEvents = new();
EventBasedNetListener firstEvents = new();
EventBasedNetListener secondEvents = new();
hostEvents.ConnectionRequestEvent += request => request.Accept();
NetPeer? firstPeer = null;
NetPeer? secondPeer = null;
firstEvents.PeerConnectedEvent += peer => firstPeer = peer;
secondEvents.PeerConnectedEvent += peer => secondPeer = peer;
NetManager hostManager = new(hostEvents);
NetManager firstManager = new(firstEvents);
NetManager secondManager = new(secondEvents);
try
{
Assert.True(hostManager.Start(0));
Assert.True(firstManager.Start(0));
Assert.True(secondManager.Start(0));
IPEndPoint hostEndpoint = new(IPAddress.Loopback, hostManager.LocalPort);
firstManager.Connect(hostEndpoint, "echo-test");
secondManager.Connect(hostEndpoint, "echo-test");
await PumpUntilAsync(
() => firstPeer is not null && secondPeer is not null,
hostManager,
firstManager,
secondManager,
timeout.Token);
using DirectEchoProtocol host = new(hostEvents, host: true);
using DirectEchoProtocol first = new(firstEvents, host: false);
using DirectEchoProtocol second = new(secondEvents, host: false);
int hostCompletions = 0;
host.ExchangeCompleted += _ => hostCompletions++;
first.BeginJoin(firstPeer!);
second.BeginJoin(secondPeer!);
await PumpUntilAsync(
() => first.Completion.IsCompleted
&& second.Completion.IsCompleted
&& hostCompletions == 2,
hostManager,
firstManager,
secondManager,
timeout.Token);
Assert.Equal(2, hostCompletions);
}
finally
{
firstManager.Stop();
secondManager.Stop();
hostManager.Stop();
}
}
private static async Task PumpUntilAsync(
Func<bool> predicate,
NetManager host,
NetManager first,
NetManager second,
CancellationToken cancellationToken)
{
while (!predicate())
{
cancellationToken.ThrowIfCancellationRequested();
host.PollEvents();
first.PollEvents();
second.PollEvents();
await Task.Delay(2, cancellationToken);
}
}
}

Some files were not shown because too many files have changed in this diff Show More