Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be732de7c9 | |||
| 88ef946af5 | |||
| 2ff7cd6d9d | |||
| 7e3be2cad1 | |||
| 94aba8a3bb | |||
| b4b6072fe1 |
@@ -35,3 +35,56 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
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
|
||||
|
||||
@@ -17,7 +17,7 @@ Rendezvous is intended to provide:
|
||||
- Isolation by game, environment, protocol version, and region.
|
||||
- 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
|
||||
|
||||
@@ -75,7 +75,13 @@ The initial service does not provide:
|
||||
|
||||
## 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
|
||||
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).
|
||||
Tenant policy, publisher/operator principals, and production key custody are
|
||||
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
|
||||
|
||||
@@ -99,7 +115,8 @@ dotnet test Rendezvous.slnx --configuration Release --no-build
|
||||
Run the bootstrap server with
|
||||
`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 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
|
||||
key references resolve to valid key material; no reusable game secret is stored
|
||||
in this repository or the public Client package.
|
||||
|
||||
+2026
-10
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
|
||||
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
|
||||
256-character NAT token ceiling. State retains keyed credential fingerprints,
|
||||
derivation inputs, and salt—not issued plaintext. All diagnostic string
|
||||
representations redact credentials and derivation material.
|
||||
256-character NAT token ceiling. The connection ticket uses half of that payload
|
||||
for its attempt ID and half for an independently derived 128-bit authenticator, so
|
||||
the SDK can correlate concurrent introductions without increasing UDP response
|
||||
size. State retains keyed credential fingerprints, derivation inputs, and salt—not
|
||||
issued plaintext. All diagnostic string representations redact credentials and
|
||||
derivation material.
|
||||
|
||||
The client receives only its punch capability. A host polls its own listing with
|
||||
the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
|
||||
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
|
||||
identical join request returns the same live attempt; changing the request under
|
||||
the same owner/key conflicts. A client may cancel with its punch capability in
|
||||
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the
|
||||
attempt. Listing deletion, expiry, revocation, or process restart removes every
|
||||
associated attempt and credential fingerprint.
|
||||
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically marks the attempt
|
||||
and retains a bounded tombstone until its original expiry. Host polling returns
|
||||
that tombstone so a coordinator can revoke any local ticket authorization, while
|
||||
endpoint binding, introduction, ticket issuance, and ticket consumption all
|
||||
reject the cancelled attempt. Listing deletion, expiry, revocation, or process
|
||||
restart removes every associated attempt and credential fingerprint.
|
||||
|
||||
Endpoint binding remains role- and capability-specific. The first endpoint
|
||||
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
|
||||
@@ -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,
|
||||
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/
|
||||
revoked/replayed tickets, and zeroes retained digests and key material on disposal.
|
||||
Issue #11 carries the ticket in the authenticated introduction; issue #12 wires
|
||||
authorization and consumption into the caller-owned LiteNetLib coordinator.
|
||||
Issue #11 carries the fixed-size ticket in the authenticated introduction. Issue
|
||||
#12 extracts its embedded attempt ID, bounds the host's local authorization window
|
||||
by both the host-polled attempt expiry and the configured ticket lifetime, then
|
||||
wires one-time consumption into the caller-owned coordinator. Both peers receive
|
||||
a digest of the exact expected ticket over HTTP and reject any syntactically valid
|
||||
but unauthenticated introduction token. Embedding the ID prevents concurrent or
|
||||
late introductions from cross-binding a valid ticket while preserving the
|
||||
mediator's 2.0 response-byte amplification ceiling.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-16
|
||||
- Tracking: #13
|
||||
|
||||
## Context
|
||||
|
||||
A connection can stop in the directory, authorization, mediation, NAT traversal,
|
||||
or direct-connection phase. Those failures have different authorities: an HTTP
|
||||
response can authoritatively reject a join, the SDK can observe a local timeout,
|
||||
and only the remote host can reject a direct connection. Treating all of them as
|
||||
one message or generic timeout would make player guidance, retry policy, tests,
|
||||
and operational measurements unreliable.
|
||||
|
||||
UDP loss, service silence, cancellation, and late LiteNetLib callbacks also make
|
||||
completion races unavoidable. Games need one terminal result and bounded work,
|
||||
not a sequence of contradictory callbacks. Direct traversal cannot be guaranteed,
|
||||
but v1 has no gameplay relay and must not imply otherwise.
|
||||
|
||||
## Decision
|
||||
|
||||
### Closed typed outcome model
|
||||
|
||||
`ConnectionOutcomeKind` is the stable wire-level terminal set: connected,
|
||||
cancelled, directory not found, attempt expired, incompatible protocol,
|
||||
unauthorized, rate limited, no host presence, service unavailable or rejected,
|
||||
mediator unavailable, punch timeout, direct-connect timeout, host rejection,
|
||||
transport error, manager stopped, and disposed.
|
||||
|
||||
The already-frozen v1 members `TimedOut`, `StaleHost`, `TransportFailed`, and
|
||||
`FallbackOffered` retain their original numeric values for source and wire
|
||||
compatibility. New SDK code never emits them. The report service accepts them,
|
||||
normalizes the first three to their precise modern equivalents, and does not let
|
||||
legacy compatibility weaken the typed coordinator result.
|
||||
|
||||
The client adds `RendezvousConnectionOutcomeSource`, failure category, and phase.
|
||||
These fields preserve authority instead of guessing from text:
|
||||
|
||||
- `RendezvousService` is used only for an HTTP decision or bounded service
|
||||
silence. Its optional `ServiceError` retains the stable service error code.
|
||||
- `LocalTraversal` reports local punch, direct-connect, and transport
|
||||
observations.
|
||||
- `RemoteHost` reports an explicit direct-connection rejection.
|
||||
- `Caller` and `Lifecycle` distinguish cancellation from manager shutdown or
|
||||
disposal.
|
||||
|
||||
Messages remain diagnostic and are never parsed into outcomes. A successful NAT
|
||||
introduction is only a transition to direct connection; `Connected` is emitted
|
||||
only after LiteNetLib reports the authenticated peer connected.
|
||||
|
||||
Join issuance is exposed as `RendezvousConnectionStartResult`, containing exactly
|
||||
one issued attempt or one terminal service outcome. Once an attempt is issued,
|
||||
the coordinator owns its local terminal outcome. Completion is exactly once;
|
||||
terminal paths release SDK subscriptions so late introductions, peer callbacks,
|
||||
network errors, cancellation, and polling are inert.
|
||||
|
||||
### Bounded phases and retries
|
||||
|
||||
Each HTTP try has a five-second default silence budget, configurable from above
|
||||
zero through thirty seconds. Only safe operations use the existing bounded retry
|
||||
policy, honoring caller cancellation and server retry guidance. Exhausting that
|
||||
budget returns `ServiceUnavailable`; it never waits indefinitely.
|
||||
|
||||
Traversal has independent defaults: ten seconds for punch/mediation and five
|
||||
seconds for the direct connection. Both are configurable up to thirty seconds.
|
||||
Local budgets, retry schedules, and elapsed duration use monotonic time, so a
|
||||
wall-clock correction cannot extend them or produce a negative duration. The
|
||||
signed attempt expiry is converted to an additional monotonic upper bound when
|
||||
the attempt is received. Punch retries retain
|
||||
their bounded request count and exponential backoff; crossing a phase deadline
|
||||
completes exactly once even if a delayed packet later arrives. Tests use an
|
||||
injected clock and do not depend on wall-clock sleeps.
|
||||
|
||||
### Explicit dedicated fallback handoff
|
||||
|
||||
A publisher may attach one validated dedicated endpoint to registration or
|
||||
update only when the tenant's provisioned fallback policy allows it. The server
|
||||
copies that endpoint into browser and issued-attempt contracts.
|
||||
The client coordinator defensively copies it into every terminal outcome; a game
|
||||
may override it locally through `DedicatedFallbackOverride`.
|
||||
|
||||
The SDK never opens, dials, reserves, probes, or authenticates the fallback. The
|
||||
game decides whether the outcome permits fallback, presents any player choice,
|
||||
and connects through its own gameplay transport and admission rules. Absence of
|
||||
an endpoint is an honest no-fallback result. Gameplay relay is absent from v1.
|
||||
|
||||
### Privacy-safe optional reporting
|
||||
|
||||
After an issued attempt completes, the game may explicitly report its outcome
|
||||
with the short-lived client punch capability. Reporting is authenticated and
|
||||
idempotent: an exact repeat succeeds as a duplicate, while a conflicting repeat
|
||||
is rejected. Reports contain only an allowlisted outcome enum and one coarse
|
||||
elapsed bucket (`<1s`, `1–5s`, `5–15s`, `15–30s`, or `30s+`). They contain no
|
||||
diagnostic message, exact duration, endpoint, metadata, player identifier, or
|
||||
credential.
|
||||
|
||||
Frozen v1 DTOs still expose `elapsedMilliseconds` and `diagnosticCode`. They are
|
||||
deprecated compatibility inputs: the current SDK omits them, the service
|
||||
immediately buckets legacy elapsed time, and neither exact timing nor diagnostic
|
||||
text is retained, logged, or used as a metric dimension.
|
||||
|
||||
The store retains a bounded capability-fingerprint tombstone long enough to
|
||||
accept a report after the live attempt expires. Metrics count the first accepted
|
||||
outcome only and use only outcome plus elapsed bucket as dimensions. Service
|
||||
issuance failures cannot be reported because no attempt capability was issued.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Player-facing UI can map stable outcome/category pairs to localized guidance
|
||||
without exposing diagnostic strings.
|
||||
- Service rejection, remote-host rejection, and local observation remain
|
||||
distinguishable for retry and support decisions.
|
||||
- Games own fallback policy and gameplay admission; Rendezvous does not claim a
|
||||
guaranteed connection path.
|
||||
- Outcome additions are contract changes and require OpenAPI, serialization,
|
||||
public API, fake-clock, late-event, and idempotency coverage.
|
||||
@@ -12,6 +12,7 @@ decision requires a superseding ADR and corresponding contract/test updates.
|
||||
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
|
||||
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
|
||||
- [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md)
|
||||
- [ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback](0010-typed-connection-outcomes-and-fallback.md)
|
||||
- [Threat model](../security/threat-model.md)
|
||||
- [Security promise and test matrix](../security/control-matrix.md)
|
||||
- [Versioned HTTP and UDP contracts](../contracts/README.md)
|
||||
|
||||
@@ -40,9 +40,7 @@ the same value as a required query parameter.
|
||||
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
|
||||
|
||||
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
|
||||
shape reference for parameters, bodies, and responses. Contract-only endpoints
|
||||
return `501` until their behavior is implemented by the subsequent directory,
|
||||
lease, and join-orchestration issues.
|
||||
shape reference for parameters, bodies, and responses.
|
||||
|
||||
Host polling sends its reusable lease credential in
|
||||
`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
|
||||
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
|
||||
|
||||
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` |
|
||||
| 403 | `forbidden` |
|
||||
| 404 | `notFound` |
|
||||
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` |
|
||||
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected` |
|
||||
| 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) |
|
||||
| 500 | `internalError` |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using LiteNetLib;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public enum RendezvousConnectionOutcomeSource
|
||||
{
|
||||
RendezvousService = 1,
|
||||
LocalTraversal = 2,
|
||||
RemoteHost = 3,
|
||||
Caller = 4,
|
||||
Lifecycle = 5,
|
||||
}
|
||||
|
||||
public enum RendezvousConnectionFailureCategory
|
||||
{
|
||||
None = 0,
|
||||
Directory = 1,
|
||||
Compatibility = 2,
|
||||
Authorization = 3,
|
||||
Capacity = 4,
|
||||
HostPresence = 5,
|
||||
Service = 6,
|
||||
Mediation = 7,
|
||||
NatTraversal = 8,
|
||||
DirectConnection = 9,
|
||||
Lifecycle = 10,
|
||||
}
|
||||
|
||||
public enum RendezvousConnectionPhase
|
||||
{
|
||||
Directory = 1,
|
||||
Authorization = 2,
|
||||
Mediation = 3,
|
||||
NatTraversal = 4,
|
||||
DirectConnection = 5,
|
||||
Complete = 6,
|
||||
}
|
||||
|
||||
public sealed class RendezvousConnectionOutcome
|
||||
{
|
||||
private readonly NetworkEndpoint? _dedicatedFallback;
|
||||
|
||||
private RendezvousConnectionOutcome(
|
||||
ConnectionOutcomeKind kind,
|
||||
RendezvousConnectionOutcomeSource source,
|
||||
RendezvousConnectionFailureCategory category,
|
||||
RendezvousConnectionPhase phase,
|
||||
TimeSpan elapsed,
|
||||
RendezvousErrorCode? serviceError,
|
||||
NetworkEndpoint? dedicatedFallback,
|
||||
NetPeer? peer)
|
||||
{
|
||||
if (elapsed < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(elapsed));
|
||||
}
|
||||
|
||||
if (dedicatedFallback is not null
|
||||
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
|
||||
{
|
||||
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
|
||||
}
|
||||
|
||||
Kind = kind;
|
||||
Source = source;
|
||||
Category = category;
|
||||
Phase = phase;
|
||||
Elapsed = elapsed;
|
||||
ServiceError = serviceError;
|
||||
_dedicatedFallback = RendezvousEndpoint.Copy(dedicatedFallback);
|
||||
Peer = peer;
|
||||
}
|
||||
|
||||
public ConnectionOutcomeKind Kind { get; }
|
||||
public RendezvousConnectionOutcomeSource Source { get; }
|
||||
public RendezvousConnectionFailureCategory Category { get; }
|
||||
public RendezvousConnectionPhase Phase { get; }
|
||||
public TimeSpan Elapsed { get; }
|
||||
public RendezvousErrorCode? ServiceError { get; }
|
||||
public NetworkEndpoint? DedicatedFallback => RendezvousEndpoint.Copy(_dedicatedFallback);
|
||||
public NetPeer? Peer { get; }
|
||||
public bool IsSuccess => Kind == ConnectionOutcomeKind.Connected;
|
||||
public bool HasDedicatedFallback => _dedicatedFallback is not null;
|
||||
|
||||
public static RendezvousConnectionOutcome FromServiceError(
|
||||
RendezvousErrorCode error,
|
||||
TimeSpan elapsed,
|
||||
NetworkEndpoint? dedicatedFallback = null)
|
||||
{
|
||||
if (error == RendezvousErrorCode.None)
|
||||
{
|
||||
throw new ArgumentException("A service failure outcome requires an error.", nameof(error));
|
||||
}
|
||||
|
||||
(ConnectionOutcomeKind kind, RendezvousConnectionFailureCategory category, RendezvousConnectionPhase phase) =
|
||||
error switch
|
||||
{
|
||||
RendezvousErrorCode.NotFound => (
|
||||
ConnectionOutcomeKind.DirectoryNotFound,
|
||||
RendezvousConnectionFailureCategory.Directory,
|
||||
RendezvousConnectionPhase.Directory),
|
||||
RendezvousErrorCode.Expired => (
|
||||
ConnectionOutcomeKind.AttemptExpired,
|
||||
RendezvousConnectionFailureCategory.Authorization,
|
||||
RendezvousConnectionPhase.Authorization),
|
||||
RendezvousErrorCode.IncompatibleProtocol => (
|
||||
ConnectionOutcomeKind.IncompatibleProtocol,
|
||||
RendezvousConnectionFailureCategory.Compatibility,
|
||||
RendezvousConnectionPhase.Directory),
|
||||
RendezvousErrorCode.AuthenticationRequired
|
||||
or RendezvousErrorCode.Forbidden
|
||||
or RendezvousErrorCode.ReplayRejected => (
|
||||
ConnectionOutcomeKind.Unauthorized,
|
||||
RendezvousConnectionFailureCategory.Authorization,
|
||||
RendezvousConnectionPhase.Authorization),
|
||||
RendezvousErrorCode.RateLimited
|
||||
or RendezvousErrorCode.CapacityExceeded => (
|
||||
ConnectionOutcomeKind.RateLimited,
|
||||
RendezvousConnectionFailureCategory.Capacity,
|
||||
RendezvousConnectionPhase.Authorization),
|
||||
RendezvousErrorCode.StaleHost => (
|
||||
ConnectionOutcomeKind.NoHostPresence,
|
||||
RendezvousConnectionFailureCategory.HostPresence,
|
||||
RendezvousConnectionPhase.Mediation),
|
||||
RendezvousErrorCode.ServiceUnavailable => (
|
||||
ConnectionOutcomeKind.ServiceUnavailable,
|
||||
RendezvousConnectionFailureCategory.Service,
|
||||
RendezvousConnectionPhase.Authorization),
|
||||
_ => (
|
||||
ConnectionOutcomeKind.ServiceRejected,
|
||||
RendezvousConnectionFailureCategory.Service,
|
||||
RendezvousConnectionPhase.Authorization),
|
||||
};
|
||||
return new(
|
||||
kind,
|
||||
RendezvousConnectionOutcomeSource.RendezvousService,
|
||||
category,
|
||||
phase,
|
||||
elapsed,
|
||||
error,
|
||||
dedicatedFallback,
|
||||
null);
|
||||
}
|
||||
|
||||
public static ConnectionElapsedBucket BucketElapsed(TimeSpan elapsed)
|
||||
{
|
||||
if (elapsed < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(elapsed));
|
||||
}
|
||||
|
||||
return elapsed.TotalSeconds switch
|
||||
{
|
||||
< 1 => ConnectionElapsedBucket.UnderOneSecond,
|
||||
< 5 => ConnectionElapsedBucket.OneToFiveSeconds,
|
||||
< 15 => ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||
< 30 => ConnectionElapsedBucket.FifteenToThirtySeconds,
|
||||
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
|
||||
};
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"[RendezvousConnectionOutcome {Kind}; {Source}; credentials redacted]";
|
||||
|
||||
internal static RendezvousConnectionOutcome Create(
|
||||
ConnectionOutcomeKind kind,
|
||||
RendezvousConnectionOutcomeSource source,
|
||||
RendezvousConnectionFailureCategory category,
|
||||
RendezvousConnectionPhase phase,
|
||||
TimeSpan elapsed,
|
||||
NetworkEndpoint? dedicatedFallback = null,
|
||||
NetPeer? peer = null) => new(
|
||||
kind,
|
||||
source,
|
||||
category,
|
||||
phase,
|
||||
elapsed,
|
||||
null,
|
||||
dedicatedFallback,
|
||||
peer);
|
||||
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class RendezvousConnectionStartResult
|
||||
{
|
||||
internal RendezvousConnectionStartResult(
|
||||
CreateJoinAttemptResponse? attempt,
|
||||
RendezvousConnectionOutcome? outcome)
|
||||
{
|
||||
if ((attempt is null) == (outcome is null))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A connection start result requires exactly one attempt or terminal outcome.");
|
||||
}
|
||||
|
||||
Attempt = attempt;
|
||||
Outcome = outcome;
|
||||
}
|
||||
|
||||
public CreateJoinAttemptResponse? Attempt { get; }
|
||||
public RendezvousConnectionOutcome? Outcome { get; }
|
||||
public bool IsReadyForTraversal => Attempt is not null;
|
||||
public bool IsCompleted => Outcome is not null;
|
||||
|
||||
public static RendezvousConnectionStartResult ReadyForTraversal(
|
||||
CreateJoinAttemptResponse attempt) => new(
|
||||
attempt ?? throw new ArgumentNullException(nameof(attempt)),
|
||||
null);
|
||||
|
||||
public static RendezvousConnectionStartResult Completed(
|
||||
RendezvousConnectionOutcome outcome) => new(
|
||||
null,
|
||||
outcome ?? throw new ArgumentNullException(nameof(outcome)));
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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`.
|
||||
|
||||
```csharp
|
||||
@@ -29,6 +29,12 @@ RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAs
|
||||
DisplayName = "My server",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||
DedicatedFallback = new()
|
||||
{
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
Address = "203.0.113.40",
|
||||
Port = 7777,
|
||||
},
|
||||
},
|
||||
publisherCredential,
|
||||
cancellationToken);
|
||||
@@ -53,10 +59,106 @@ string presenceToken = NatPunchRequestTokenCodec.Encode(
|
||||
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
|
||||
```
|
||||
|
||||
The same codec creates `Host` tokens for host-polled invitations and `Client`
|
||||
tokens for a created join attempt. Always send them from the same LiteNetLib
|
||||
socket that will carry the direct game connection; the mediator ignores any
|
||||
caller-supplied public endpoint.
|
||||
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:
|
||||
|
||||
@@ -90,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
|
||||
keyed ticket digests are zeroed.
|
||||
|
||||
See the repository's ADR 0007 for HTTP ownership/retry semantics and ADR 0008 for
|
||||
join-capability and connection-ticket security semantics.
|
||||
See the repository's ADR 0007 for HTTP ownership/retry semantics, ADR 0008 for
|
||||
join-capability and connection-ticket security semantics, and ADR 0010 for typed
|
||||
outcomes, deadlines, reporting, and caller-owned fallback.
|
||||
|
||||
@@ -42,6 +42,10 @@ public static class RendezvousClientResult
|
||||
|
||||
public sealed class PublishedSession
|
||||
{
|
||||
private readonly object _timingGate = new();
|
||||
private DateTimeOffset _expiresAt;
|
||||
private int _leaseRenewAfterSeconds;
|
||||
|
||||
internal PublishedSession(RegisterSessionResponse response)
|
||||
{
|
||||
ListingId = response.ListingId;
|
||||
@@ -49,8 +53,8 @@ public sealed class PublishedSession
|
||||
LeaseToken = response.LeaseToken;
|
||||
HostPresenceHandle = response.HostPresenceHandle;
|
||||
HostPresenceCapability = response.HostPresenceCapability;
|
||||
ExpiresAt = response.ExpiresAt;
|
||||
LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||
_expiresAt = response.ExpiresAt;
|
||||
_leaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
|
||||
}
|
||||
|
||||
@@ -59,8 +63,41 @@ public sealed class PublishedSession
|
||||
public string LeaseToken { get; }
|
||||
public MediationHandle HostPresenceHandle { get; }
|
||||
public string HostPresenceCapability { get; }
|
||||
public DateTimeOffset ExpiresAt { get; internal set; }
|
||||
public int LeaseRenewAfterSeconds { get; internal set; }
|
||||
public DateTimeOffset ExpiresAt
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_timingGate)
|
||||
{
|
||||
return _expiresAt;
|
||||
}
|
||||
}
|
||||
internal set
|
||||
{
|
||||
lock (_timingGate)
|
||||
{
|
||||
_expiresAt = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int LeaseRenewAfterSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_timingGate)
|
||||
{
|
||||
return _leaseRenewAfterSeconds;
|
||||
}
|
||||
}
|
||||
internal set
|
||||
{
|
||||
lock (_timingGate)
|
||||
{
|
||||
_leaseRenewAfterSeconds = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
public int HostPresenceRefreshAfterSeconds { get; }
|
||||
|
||||
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
|
||||
@@ -109,6 +146,38 @@ public interface IRendezvousSessionBrowserClient
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousJoinClient
|
||||
{
|
||||
Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
|
||||
CreateJoinAttemptRequest request,
|
||||
NetworkEndpoint? dedicatedFallback = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||
CreateJoinAttemptRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<bool>> CancelAsync(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||
PublishedSession session,
|
||||
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||
PublishedSession session,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
RendezvousConnectionOutcome outcome,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousDelay
|
||||
{
|
||||
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||
@@ -117,6 +186,7 @@ public interface IRendezvousDelay
|
||||
public sealed class RendezvousClientOptions
|
||||
{
|
||||
public int MaximumSafeRetries { get; set; } = 2;
|
||||
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||
public double JitterRatio { get; set; } = 0.2;
|
||||
@@ -124,6 +194,8 @@ public sealed class RendezvousClientOptions
|
||||
internal void Validate()
|
||||
{
|
||||
if (MaximumSafeRetries is < 0 or > 5
|
||||
|| RequestTimeout <= TimeSpan.Zero
|
||||
|| RequestTimeout > TimeSpan.FromSeconds(30)
|
||||
|| InitialRetryDelay < TimeSpan.Zero
|
||||
|| MaximumRetryDelay < InitialRetryDelay
|
||||
|| MaximumRetryDelay > TimeSpan.FromSeconds(30)
|
||||
@@ -139,3 +211,15 @@ internal sealed class SystemRendezvousDelay : IRendezvousDelay
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
|
||||
Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
|
||||
internal static class RendezvousEndpoint
|
||||
{
|
||||
internal static NetworkEndpoint? Copy(NetworkEndpoint? endpoint) => endpoint is null
|
||||
? null
|
||||
: new NetworkEndpoint
|
||||
{
|
||||
AddressFamily = endpoint.AddressFamily,
|
||||
Address = endpoint.Address,
|
||||
Port = endpoint.Port,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ internal sealed class RendezvousHttpTransport
|
||||
_options = new RendezvousClientOptions
|
||||
{
|
||||
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
|
||||
RequestTimeout = suppliedOptions.RequestTimeout,
|
||||
InitialRetryDelay = suppliedOptions.InitialRetryDelay,
|
||||
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
|
||||
JitterRatio = suppliedOptions.JitterRatio,
|
||||
@@ -38,11 +39,15 @@ internal sealed class RendezvousHttpTransport
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using CancellationTokenSource requestTimeout =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
requestTimeout.CancelAfter(_options.RequestTimeout);
|
||||
CancellationToken requestCancellation = requestTimeout.Token;
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage request = requestFactory();
|
||||
using HttpResponseMessage response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestCancellation)
|
||||
.ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -54,7 +59,7 @@ internal sealed class RendezvousHttpTransport
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||
payload = await ReadBoundedAsync(response.Content, requestCancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
@@ -81,7 +86,7 @@ internal sealed class RendezvousHttpTransport
|
||||
: 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);
|
||||
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
|
||||
{
|
||||
|
||||
@@ -88,6 +88,7 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
||||
DisplayName = request.DisplayName,
|
||||
Capacity = CopyCapacity(request.Capacity),
|
||||
Metadata = CopyMetadata(request.Metadata),
|
||||
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
|
||||
};
|
||||
return _transport.SendSafeAsync<bool>(
|
||||
() => RendezvousHttpTransport.JsonRequest(
|
||||
@@ -138,6 +139,7 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
||||
Visibility = request.Visibility,
|
||||
Capacity = CopyCapacity(request.Capacity),
|
||||
Metadata = CopyMetadata(request.Metadata),
|
||||
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
|
||||
};
|
||||
|
||||
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) =>
|
||||
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,
|
||||
TransportFailed = 8,
|
||||
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
|
||||
|
||||
@@ -45,6 +45,23 @@ public static class ContractValidation
|
||||
public static bool IsDiagnosticCodeValid(string? value) =>
|
||||
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) =>
|
||||
!string.IsNullOrWhiteSpace(value)
|
||||
&& 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]
|
||||
public string ClientPunchCapability { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public string ConnectionTicketDigest { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||
@@ -53,6 +56,12 @@ public sealed class HostJoinAttempt
|
||||
[JsonRequired]
|
||||
public string HostPunchCapability { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public string ConnectionTicketDigest { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public bool IsCancelled { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -67,26 +76,3 @@ public sealed class BrowseHostJoinAttemptsResponse
|
||||
|
||||
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]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
||||
|
||||
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegisterSessionRequest
|
||||
@@ -75,6 +77,8 @@ public sealed class RegisterSessionRequest
|
||||
|
||||
[JsonRequired]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
||||
|
||||
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegisterSessionResponse
|
||||
@@ -147,6 +151,8 @@ public sealed class UpdateSessionRequest
|
||||
|
||||
[JsonRequired]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
||||
|
||||
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeleteSessionRequest
|
||||
|
||||
@@ -25,7 +25,9 @@ public static class ContractJson
|
||||
|
||||
options.AllowTrailingCommas = false;
|
||||
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.PropertyNameCaseInsensitive = false;
|
||||
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,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.Value,
|
||||
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 FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
@@ -11,8 +13,6 @@ namespace FinalFactory.Rendezvous.Server.Http;
|
||||
|
||||
internal static class ContractEndpoints
|
||||
{
|
||||
private const int NotImplementedStatus = StatusCodes.Status501NotImplemented;
|
||||
|
||||
public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
|
||||
this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
@@ -21,6 +21,7 @@ internal static class ContractEndpoints
|
||||
.Accepts<RegisterSessionRequest>("application/json")
|
||||
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
@@ -32,45 +33,54 @@ internal static class ContractEndpoints
|
||||
.Accepts<RenewLeaseRequest>("application/json")
|
||||
.Produces<RenewLeaseResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("RenewSessionLease");
|
||||
sessions.MapPut("/{listingId}", UpdateSession)
|
||||
.Accepts<UpdateSessionRequest>("application/json")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.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("UpdateSession");
|
||||
sessions.MapDelete("/{listingId}", DeleteSession)
|
||||
.Accepts<DeleteSessionRequest>("application/json")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("DeleteSession");
|
||||
sessions.MapGet("/", BrowseSessions)
|
||||
.Produces<BrowseSessionsResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("BrowseSessions");
|
||||
sessions.MapGet("/{listingId}", GetSession)
|
||||
.Produces<GetSessionResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("GetSession");
|
||||
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
||||
.Produces<BrowseHostJoinAttemptsResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("BrowseHostJoinAttempts");
|
||||
|
||||
@@ -81,8 +91,10 @@ internal static class ContractEndpoints
|
||||
.Accepts<CreateJoinAttemptRequest>("application/json")
|
||||
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("CreateJoinAttempt");
|
||||
@@ -90,12 +102,18 @@ internal static class ContractEndpoints
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("CancelJoinAttempt");
|
||||
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
||||
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
||||
.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");
|
||||
|
||||
return endpoints;
|
||||
@@ -106,6 +124,7 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -119,6 +138,21 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"RegisterSession",
|
||||
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||
publisher.Subject,
|
||||
null,
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
|
||||
principal!,
|
||||
request,
|
||||
@@ -127,6 +161,7 @@ internal static class ContractEndpoints
|
||||
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult RenewLease(
|
||||
SessionListingId listingId,
|
||||
@@ -134,6 +169,7 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -147,6 +183,21 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"RenewSessionLease",
|
||||
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||
publisher.Subject,
|
||||
listingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
|
||||
principal!,
|
||||
listingId,
|
||||
@@ -156,6 +207,7 @@ internal static class ContractEndpoints
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult UpdateSession(
|
||||
SessionListingId listingId,
|
||||
@@ -163,6 +215,7 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -176,6 +229,21 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"UpdateSession",
|
||||
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,
|
||||
@@ -183,6 +251,7 @@ internal static class ContractEndpoints
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult DeleteSession(
|
||||
SessionListingId listingId,
|
||||
@@ -190,6 +259,7 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -203,6 +273,21 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"DeleteSession",
|
||||
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,
|
||||
@@ -210,6 +295,7 @@ internal static class ContractEndpoints
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult BrowseSessions(
|
||||
[FromQuery] int contractVersion,
|
||||
@@ -221,6 +307,8 @@ internal static class ContractEndpoints
|
||||
[FromQuery] bool? excludeFull,
|
||||
[FromQuery] string? cursor,
|
||||
[FromServices] SessionBrowserService browser,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||
@@ -230,6 +318,20 @@ internal static class ContractEndpoints
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"BrowseSessions",
|
||||
Tenant(parsedGameId, parsedEnvironmentId),
|
||||
null,
|
||||
null,
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
|
||||
{
|
||||
ContractVersion = contractVersion,
|
||||
@@ -245,6 +347,7 @@ internal static class ContractEndpoints
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetSession(
|
||||
SessionListingId listingId,
|
||||
@@ -253,6 +356,8 @@ internal static class ContractEndpoints
|
||||
[FromQuery] string environmentId,
|
||||
[FromQuery] uint protocolVersion,
|
||||
[FromServices] SessionBrowserService browser,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
|
||||
@@ -266,6 +371,20 @@ internal static class ContractEndpoints
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"GetSession",
|
||||
Tenant(parsedGameId, parsedEnvironmentId),
|
||||
null,
|
||||
listingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
BrowserServiceResult<GetSessionResponse> result = browser.Get(
|
||||
listingId,
|
||||
parsedGameId,
|
||||
@@ -276,6 +395,7 @@ internal static class ContractEndpoints
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult BrowseHostJoinAttempts(
|
||||
SessionListingId listingId,
|
||||
@@ -284,7 +404,23 @@ internal static class ContractEndpoints
|
||||
[FromQuery] int? pageSize,
|
||||
[FromQuery] string? cursor,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"BrowseHostJoinAttempts",
|
||||
null,
|
||||
AbuseProtectionService.FingerprintSecret(leaseToken ?? string.Empty),
|
||||
listingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
|
||||
listingId,
|
||||
@@ -297,10 +433,12 @@ internal static class ContractEndpoints
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CreateJoinAttempt(
|
||||
[FromBody] CreateJoinAttemptRequest request,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -310,6 +448,20 @@ internal static class ContractEndpoints
|
||||
}
|
||||
|
||||
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"CreateJoinAttempt",
|
||||
Tenant(request.GameId, request.EnvironmentId),
|
||||
clientSubject,
|
||||
request.ListingId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
|
||||
clientSubject,
|
||||
request,
|
||||
@@ -318,12 +470,29 @@ internal static class ContractEndpoints
|
||||
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CancelJoinAttempt(
|
||||
JoinAttemptId attemptId,
|
||||
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"CancelJoinAttempt",
|
||||
null,
|
||||
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
|
||||
attemptId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
JoinAttemptServiceResult<bool> result = attempts.Cancel(
|
||||
attemptId,
|
||||
@@ -331,19 +500,41 @@ internal static class ContractEndpoints
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult ReportConnectionOutcome(
|
||||
JoinAttemptId attemptId,
|
||||
[FromBody] ReportConnectionOutcomeRequest request) => NotImplemented();
|
||||
|
||||
private static IResult NotImplemented() => Results.Json(
|
||||
new ApiError
|
||||
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||
[FromBody] ReportConnectionOutcomeRequest request,
|
||||
[FromServices] ConnectionOutcomeService outcomes,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Code = RendezvousErrorCode.ServiceUnavailable,
|
||||
Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.",
|
||||
},
|
||||
ContractJson.Options,
|
||||
statusCode: NotImplementedStatus);
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"ReportConnectionOutcome",
|
||||
null,
|
||||
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
|
||||
attemptId.ToString(),
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
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(
|
||||
string? authorizationHeader,
|
||||
@@ -370,11 +561,41 @@ internal static class ContractEndpoints
|
||||
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
|
||||
{
|
||||
Code = code,
|
||||
Message = ErrorMessage(code),
|
||||
RetryAfterSeconds = retryAfterSeconds,
|
||||
},
|
||||
ContractJson.Options,
|
||||
statusCode: ErrorStatus(code));
|
||||
@@ -385,13 +606,27 @@ internal static class ContractEndpoints
|
||||
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
|
||||
{
|
||||
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
|
||||
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
|
||||
RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound,
|
||||
RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
|
||||
RendezvousErrorCode.Expired => StatusCodes.Status410Gone,
|
||||
RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound,
|
||||
RendezvousErrorCode.Conflict
|
||||
or RendezvousErrorCode.IncompatibleProtocol
|
||||
or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
|
||||
RendezvousErrorCode.Expired or RendezvousErrorCode.StaleHost => StatusCodes.Status410Gone,
|
||||
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
|
||||
@@ -406,7 +641,9 @@ internal static class ContractEndpoints
|
||||
RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.",
|
||||
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
|
||||
RendezvousErrorCode.Expired => "The session lease has expired.",
|
||||
RendezvousErrorCode.StaleHost => "The session has no fresh host presence.",
|
||||
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
|
||||
RendezvousErrorCode.RateLimited => "The request rate limit was exceeded.",
|
||||
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
|
||||
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
|
||||
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
|
||||
|
||||
@@ -4,7 +4,8 @@ using Microsoft.AspNetCore.Diagnostics;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Http;
|
||||
|
||||
internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
||||
internal sealed partial class RendezvousExceptionHandler(
|
||||
ILogger<RendezvousExceptionHandler> logger) : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext,
|
||||
@@ -17,16 +18,31 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
||||
}
|
||||
|
||||
bool invalidRequest = exception is BadHttpRequestException or JsonException;
|
||||
httpContext.Response.StatusCode = invalidRequest
|
||||
bool payloadTooLarge = exception is BadHttpRequestException
|
||||
{
|
||||
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(
|
||||
new ApiError
|
||||
{
|
||||
Code = invalidRequest
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.InternalError,
|
||||
Message = invalidRequest
|
||||
Message = payloadTooLarge
|
||||
? "The request body exceeds the supported size."
|
||||
: invalidRequest
|
||||
? "The request body, route, or query value is invalid."
|
||||
: "The service could not complete the request.",
|
||||
},
|
||||
@@ -34,4 +50,14 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
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 hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt);
|
||||
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
|
||||
"join-attempt-id",
|
||||
clientSubject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
string connectionTicket = NatIntroductionTokenCodec.Encode(
|
||||
attemptId,
|
||||
Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt));
|
||||
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|
||||
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|
||||
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|
||||
@@ -75,12 +83,6 @@ internal sealed class JoinAttemptService(
|
||||
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
|
||||
}
|
||||
|
||||
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
|
||||
"join-attempt-id",
|
||||
clientSubject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
|
||||
"join-mediation-handle",
|
||||
clientSubject,
|
||||
@@ -126,7 +128,10 @@ internal sealed class JoinAttemptService(
|
||||
AttemptId = persisted.AttemptId,
|
||||
MediationHandle = persisted.MediationHandle,
|
||||
ClientPunchCapability = clientCapability,
|
||||
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||
CreateConnectionTicket(persisted)),
|
||||
ExpiresAt = persisted.ExpiresAt,
|
||||
DedicatedFallback = StoredListing.CopyEndpoint(persisted.DedicatedFallback),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,7 +208,7 @@ internal sealed class JoinAttemptService(
|
||||
StoredJoinAttempt attempt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attempt);
|
||||
if (!attempt.IntroductionConsumed)
|
||||
if (!attempt.IntroductionConsumed || attempt.IsCancelled)
|
||||
{
|
||||
return new(RendezvousErrorCode.Conflict);
|
||||
}
|
||||
@@ -213,12 +218,7 @@ internal sealed class JoinAttemptService(
|
||||
return new(RendezvousErrorCode.Expired);
|
||||
}
|
||||
|
||||
string ticket = Derive(
|
||||
"connection-ticket",
|
||||
attempt.ClientSubject,
|
||||
attempt.IdempotencyKey,
|
||||
attempt.RequestFingerprint,
|
||||
attempt.CapabilityDerivationSalt);
|
||||
string ticket = CreateConnectionTicket(attempt);
|
||||
if (!ContractValidation.IsConnectionTicketValid(ticket)
|
||||
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|
||||
|| fingerprint != attempt.ConnectionTicketFingerprint)
|
||||
@@ -249,10 +249,23 @@ internal sealed class JoinAttemptService(
|
||||
AttemptId = attempt.AttemptId,
|
||||
MediationHandle = attempt.MediationHandle,
|
||||
HostPunchCapability = capability,
|
||||
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||
CreateConnectionTicket(attempt)),
|
||||
IsCancelled = attempt.IsCancelled,
|
||||
ExpiresAt = attempt.ExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private string CreateConnectionTicket(StoredJoinAttempt attempt) =>
|
||||
NatIntroductionTokenCodec.Encode(
|
||||
attempt.AttemptId,
|
||||
Derive(
|
||||
"connection-ticket",
|
||||
attempt.ClientSubject,
|
||||
attempt.IdempotencyKey,
|
||||
attempt.RequestFingerprint,
|
||||
attempt.CapabilityDerivationSalt));
|
||||
|
||||
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||
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 Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Logging.AddFilter(
|
||||
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
|
||||
LogLevel.None);
|
||||
bool isOpenApiGeneration = string.Equals(
|
||||
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
|
||||
"GetDocument.Insider",
|
||||
@@ -50,6 +58,22 @@ builder.Services.AddOpenApi("v1", static options =>
|
||||
BearerFormat = "rv1 publisher credential",
|
||||
Description = "Tenant-scoped publisher credential issued during game provisioning.",
|
||||
};
|
||||
const string attemptSchemeName = "JoinAttemptCapability";
|
||||
document.Components.SecuritySchemes[attemptSchemeName] = new OpenApiSecurityScheme
|
||||
{
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
Name = "X-Rendezvous-Client-Punch-Capability",
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Attempt-scoped client capability returned only to the joining caller.",
|
||||
};
|
||||
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)
|
||||
{
|
||||
@@ -58,7 +82,17 @@ builder.Services.AddOpenApi("v1", static options =>
|
||||
"UpdateSession",
|
||||
"DeleteSession",
|
||||
};
|
||||
HashSet<string> operatorOperations = new(StringComparer.Ordinal)
|
||||
{
|
||||
"GetOperatorStatus",
|
||||
"RevokeOperatorListing",
|
||||
"RevokeOperatorPrincipal",
|
||||
"RevokeOperatorSigningKey",
|
||||
"BeginOperatorDrain",
|
||||
};
|
||||
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)
|
||||
{
|
||||
if (path.Operations is null)
|
||||
@@ -75,6 +109,71 @@ builder.Services.AddOpenApi("v1", static options =>
|
||||
[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;
|
||||
@@ -86,6 +185,55 @@ builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
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();
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
@@ -93,8 +241,13 @@ InMemoryEphemeralRendezvousStore stateStore = new(
|
||||
stateOptions,
|
||||
rendezvousClock,
|
||||
rendezvousClock);
|
||||
builder.Services.AddSingleton(stateStore);
|
||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
||||
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
||||
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
|
||||
builder.Services.AddSingleton<RendezvousTelemetry>();
|
||||
builder.Services.AddSingleton<AuditTrail>();
|
||||
builder.Services.AddSingleton<RendezvousReadiness>();
|
||||
|
||||
if (isOpenApiGeneration)
|
||||
{
|
||||
@@ -125,6 +278,9 @@ else
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||
builder.Services.AddSingleton<JoinAttemptService>();
|
||||
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
||||
builder.Services.AddSingleton<ConnectionOutcomeService>();
|
||||
builder.Services.AddSingleton<OperatorService>();
|
||||
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||
}
|
||||
|
||||
@@ -153,31 +309,17 @@ if (!isOpenApiGeneration)
|
||||
WebApplication app = builder.Build();
|
||||
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
||||
|
||||
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
|
||||
{
|
||||
app.UseForwardedHeaders();
|
||||
}
|
||||
app.UseMiddleware<TelemetryMiddleware>();
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||
app.MapOpenApi();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
app.MapGet(
|
||||
"/health/live",
|
||||
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");
|
||||
app.MapOperatorEndpoints();
|
||||
app.MapRendezvousHealthEndpoints();
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
|
||||
@@ -142,8 +142,36 @@ internal sealed class SigningKeyRing : IDisposable
|
||||
return VerificationKeyLookup.Available;
|
||||
}
|
||||
|
||||
public bool Revoke(string keyId) =>
|
||||
_keys.ContainsKey(keyId) && _runtimeRevocations.TryAdd(keyId, 0);
|
||||
public bool Revoke(string keyId)
|
||||
{
|
||||
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()
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
private byte[]? _material;
|
||||
|
||||
@@ -55,6 +55,11 @@ internal sealed class SessionLeaseService(
|
||||
}
|
||||
|
||||
AuthorizedPublisherContext context = authorized.Context;
|
||||
if (!IsFallbackAllowed(context.Policy, request.DedicatedFallback))
|
||||
{
|
||||
return new(RendezvousErrorCode.Forbidden);
|
||||
}
|
||||
|
||||
string requestFingerprint = ComputeRegistrationFingerprint(request);
|
||||
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||
string leaseToken = capabilities.DeriveCapability(
|
||||
@@ -119,6 +124,7 @@ internal sealed class SessionLeaseService(
|
||||
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||
Metadata = request.Metadata,
|
||||
DedicatedFallback = request.DedicatedFallback,
|
||||
LeaseFingerprint = leaseFingerprint,
|
||||
HostPresenceHandle = presenceHandle,
|
||||
HostPresenceFingerprint = presenceFingerprint,
|
||||
@@ -235,10 +241,14 @@ internal sealed class SessionLeaseService(
|
||||
|
||||
StoredListing ownedListing = listing!;
|
||||
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
|
||||
if (!authorized.IsAllowed)
|
||||
if (!authorized.IsAllowed || authorized.Context is null)
|
||||
{
|
||||
return new(MapAuthorization(authorized.Error));
|
||||
}
|
||||
if (!IsFallbackAllowed(authorized.Context.Policy, request.DedicatedFallback))
|
||||
{
|
||||
return new(RendezvousErrorCode.Forbidden);
|
||||
}
|
||||
|
||||
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||
StoreResult<StoredListing> updated = store.UpdateListing(new(
|
||||
@@ -250,7 +260,8 @@ internal sealed class SessionLeaseService(
|
||||
request.DisplayName,
|
||||
request.Capacity.CurrentPlayers,
|
||||
request.Capacity.MaximumPlayers,
|
||||
request.Metadata), cancellationToken);
|
||||
request.Metadata,
|
||||
request.DedicatedFallback), cancellationToken);
|
||||
return updated.Succeeded
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(updated.Code.ToContractError());
|
||||
@@ -341,6 +352,9 @@ internal sealed class SessionLeaseService(
|
||||
metadata,
|
||||
clock.UtcNow);
|
||||
|
||||
private static bool IsFallbackAllowed(GamePolicy policy, NetworkEndpoint? fallback) =>
|
||||
fallback is null || policy.FallbackPolicy == FallbackPolicyMode.DedicatedEndpointAllowed;
|
||||
|
||||
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
@@ -359,6 +373,8 @@ internal sealed class SessionLeaseService(
|
||||
|| !Enum.IsDefined(request.Visibility)
|
||||
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||
|| request.DedicatedFallback is not null
|
||||
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
@@ -375,6 +391,8 @@ internal sealed class SessionLeaseService(
|
||||
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||
|| request.DedicatedFallback is not null
|
||||
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
@@ -424,6 +442,14 @@ internal sealed class SessionLeaseService(
|
||||
Metadata = request.Metadata
|
||||
.OrderBy(static item => item.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
|
||||
DedicatedFallback = request.DedicatedFallback is null
|
||||
? null
|
||||
: new NetworkEndpoint
|
||||
{
|
||||
AddressFamily = request.DedicatedFallback.AddressFamily,
|
||||
Address = request.DedicatedFallback.Address,
|
||||
Port = request.DedicatedFallback.Port,
|
||||
},
|
||||
};
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
|
||||
byte[] digest = SHA256.HashData(encoded);
|
||||
|
||||
@@ -29,6 +29,7 @@ internal sealed record EphemeralStoreOptions
|
||||
public int MaxListings { get; init; } = 25_000;
|
||||
public int MaxPresenceBindings { get; init; } = 25_000;
|
||||
public int MaxJoinAttempts { get; init; } = 10_000;
|
||||
public int MaxOutcomeReports { get; init; } = 35_000;
|
||||
public int MaxReplayEntries { get; init; } = 30_000;
|
||||
public int MaxRevocations { get; init; } = 10_000;
|
||||
public int MaxIdempotencyEntries { get; init; } = 35_000;
|
||||
@@ -45,6 +46,7 @@ internal sealed record EphemeralStoreOptions
|
||||
RequirePositive(MaxListings, nameof(MaxListings));
|
||||
RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings));
|
||||
RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts));
|
||||
RequirePositive(MaxOutcomeReports, nameof(MaxOutcomeReports));
|
||||
RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries));
|
||||
RequirePositive(MaxRevocations, nameof(MaxRevocations));
|
||||
RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries));
|
||||
@@ -173,6 +175,7 @@ internal sealed record ListingDefinition
|
||||
public required int CurrentPlayers { get; init; }
|
||||
public required int MaximumPlayers { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
|
||||
public NetworkEndpoint? DedicatedFallback { get; init; }
|
||||
public required SecretFingerprint LeaseFingerprint { get; init; }
|
||||
public required MediationHandle HostPresenceHandle { get; init; }
|
||||
public required SecretFingerprint HostPresenceFingerprint { get; init; }
|
||||
@@ -189,6 +192,16 @@ internal sealed record StoredListing
|
||||
public static ListingDefinition Freeze(ListingDefinition source) => source with
|
||||
{
|
||||
Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal),
|
||||
DedicatedFallback = CopyEndpoint(source.DedicatedFallback),
|
||||
};
|
||||
|
||||
internal static NetworkEndpoint? CopyEndpoint(NetworkEndpoint? endpoint) => endpoint is null
|
||||
? null
|
||||
: new NetworkEndpoint
|
||||
{
|
||||
AddressFamily = endpoint.AddressFamily,
|
||||
Address = endpoint.Address,
|
||||
Port = endpoint.Port,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,7 +227,8 @@ internal sealed record UpdateListingCommand(
|
||||
string DisplayName,
|
||||
int CurrentPlayers,
|
||||
int MaximumPlayers,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
NetworkEndpoint? DedicatedFallback);
|
||||
|
||||
internal sealed record DeleteListingCommand(
|
||||
SessionListingId ListingId,
|
||||
@@ -257,6 +271,7 @@ internal sealed record CreateJoinAttemptCommand
|
||||
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||
public required string CapabilityDerivationSalt { get; init; }
|
||||
public NetworkEndpoint? DedicatedFallback { get; init; }
|
||||
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
||||
|
||||
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
|
||||
@@ -280,12 +295,15 @@ internal sealed record StoredJoinAttempt
|
||||
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||
public NetworkEndpoint? DedicatedFallback { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public required TimeSpan CreatedAtMonotonic { get; init; }
|
||||
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
||||
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
||||
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||
public required bool IntroductionConsumed { get; init; }
|
||||
public required bool ConnectionTicketConsumed { get; init; }
|
||||
public required bool IsCancelled { get; init; }
|
||||
|
||||
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
||||
}
|
||||
@@ -315,6 +333,16 @@ internal sealed record CancelJoinAttemptCommand(
|
||||
JoinAttemptId AttemptId,
|
||||
SecretFingerprint ClientCapabilityFingerprint);
|
||||
|
||||
internal sealed record ReportConnectionOutcomeCommand(
|
||||
JoinAttemptId AttemptId,
|
||||
SecretFingerprint ClientCapabilityFingerprint,
|
||||
ConnectionOutcomeKind Outcome,
|
||||
ConnectionElapsedBucket ElapsedBucket);
|
||||
|
||||
internal sealed record StoredConnectionOutcome(
|
||||
ConnectionOutcomeKind Outcome,
|
||||
ConnectionElapsedBucket ElapsedBucket);
|
||||
|
||||
internal sealed record ConsumeConnectionTicketCommand(
|
||||
JoinAttemptId AttemptId,
|
||||
SecretFingerprint ConnectionTicketFingerprint);
|
||||
@@ -335,6 +363,8 @@ internal enum StoreResultCode
|
||||
Draining = 6,
|
||||
ReplayRejected = 7,
|
||||
ServiceUnavailable = 8,
|
||||
StaleHost = 9,
|
||||
IncompatibleProtocol = 10,
|
||||
}
|
||||
|
||||
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<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredConnectionOutcome> ReportConnectionOutcome(ReportConnectionOutcomeCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
||||
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
|
||||
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -15,6 +15,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
|
||||
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
|
||||
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
|
||||
private readonly Dictionary<JoinAttemptId, OutcomeReportEntry> _outcomeReports = [];
|
||||
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
|
||||
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
|
||||
@@ -22,7 +23,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
private TimeSpan? _drainDeadline;
|
||||
private TimeSpan _nextUdpMaintenance;
|
||||
private long _maintenanceSweepCount;
|
||||
private long _expiryChurn;
|
||||
private bool _available = true;
|
||||
private EphemeralStoreSnapshot? _metricsSnapshot;
|
||||
private TimeSpan _metricsSnapshotAt = TimeSpan.MinValue;
|
||||
|
||||
public InMemoryEphemeralRendezvousStore(
|
||||
EphemeralStoreOptions options,
|
||||
@@ -65,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(
|
||||
CreateListingCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
|
||||
@@ -183,7 +231,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
||||
|| command.CurrentPlayers < 0
|
||||
|| 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));
|
||||
}
|
||||
@@ -213,6 +263,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
CurrentPlayers = command.CurrentPlayers,
|
||||
MaximumPlayers = command.MaximumPlayers,
|
||||
Metadata = command.Metadata,
|
||||
DedicatedFallback = command.DedicatedFallback,
|
||||
});
|
||||
entry.Version++;
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
@@ -362,14 +413,26 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
|
||||
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? listing)
|
||||
|| listing.Definition.Scope != command.Scope
|
||||
|| listing.Definition.ProtocolVersion != command.ProtocolVersion
|
||||
|| !_presence.ContainsKey(listing.Definition.HostPresenceHandle))
|
||||
|| listing.Definition.Scope != command.Scope)
|
||||
{
|
||||
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
|
||||
|| _outcomeReports.Count >= _options.MaxOutcomeReports
|
||||
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
||||
|| _attempts.Values.Count(entry => entry.Command.Scope == command.Scope)
|
||||
>= command.ScopeAttemptLimit)
|
||||
@@ -384,9 +447,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|
||||
AttemptEntry attempt = new(
|
||||
command,
|
||||
now,
|
||||
now + _options.JoinAttemptLifetime,
|
||||
WallDeadline(now, _options.JoinAttemptLifetime));
|
||||
_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);
|
||||
_idempotency.Add(idempotencyKey, new(
|
||||
command.RequestFingerprint,
|
||||
@@ -420,7 +489,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|
||||
IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values
|
||||
.Where(entry => entry.Command.ListingId == query.ListingId
|
||||
&& !entry.IntroductionConsumed
|
||||
&& (!entry.IntroductionConsumed || entry.IsCancelled)
|
||||
&& (!query.AfterAttemptId.HasValue
|
||||
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
|
||||
.OrderBy(static entry => entry.Command.AttemptId.Value)
|
||||
@@ -451,15 +520,51 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
if (attempt.IntroductionConsumed)
|
||||
if (attempt.IsCancelled)
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
return new(StoreResultCode.Success, true, true);
|
||||
}
|
||||
|
||||
RemoveAttempt(command.AttemptId);
|
||||
attempt.IsCancelled = true;
|
||||
return new(StoreResultCode.Success, true);
|
||||
}, 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(
|
||||
BindAttemptEndpointCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(now =>
|
||||
@@ -480,7 +585,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|
||||
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|
||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
||||
|| attempt.Deadline <= now)
|
||||
|| attempt.Deadline <= now
|
||||
|| attempt.IsCancelled)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
@@ -532,7 +638,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|
||||
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|
||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
||||
|| attempt.Deadline <= now)
|
||||
|| attempt.Deadline <= now
|
||||
|| attempt.IsCancelled)
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
@@ -590,6 +697,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
if (attempt.IsCancelled)
|
||||
{
|
||||
return new(StoreResultCode.Conflict);
|
||||
}
|
||||
|
||||
if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now)
|
||||
{
|
||||
return new(StoreResultCode.Expired);
|
||||
@@ -665,6 +777,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return new(StoreResultCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
int activeResourcesBefore = _listings.Count
|
||||
+ _presence.Count
|
||||
+ _attempts.Count
|
||||
+ _outcomeReports.Count;
|
||||
_revocations[subject] = now + lifetime;
|
||||
SessionListingId[] listings = _listings
|
||||
.Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal))
|
||||
@@ -674,6 +790,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
.Where(item => string.Equals(item.Value.Command.ClientSubject, subject, StringComparison.Ordinal))
|
||||
.Select(static item => item.Key)
|
||||
.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)
|
||||
{
|
||||
RemoveListing(listingId);
|
||||
@@ -683,8 +803,16 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
{
|
||||
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);
|
||||
|
||||
public void BeginDrain(CancellationToken cancellationToken = default)
|
||||
@@ -696,6 +824,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
if (!_drainDeadline.HasValue)
|
||||
{
|
||||
_drainDeadline = _monotonicClock.Elapsed + _options.GracefulDrainLifetime;
|
||||
_metricsSnapshot = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,6 +835,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
{
|
||||
_available = false;
|
||||
ClearActiveState();
|
||||
_metricsSnapshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,7 +859,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
_nextUdpMaintenance = now + UdpMaintenanceInterval;
|
||||
}
|
||||
|
||||
return operation(now);
|
||||
StoreResult<T> result = operation(now);
|
||||
_metricsSnapshot = null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -766,36 +898,54 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
ClearActiveState();
|
||||
}
|
||||
|
||||
RemoveExpired(_revocations, now);
|
||||
RemoveExpired(_replay, now);
|
||||
foreach (string key in _idempotency
|
||||
_expiryChurn += RemoveExpired(_revocations, now);
|
||||
_expiryChurn += RemoveExpired(_replay, now);
|
||||
string[] expiredIdempotency = _idempotency
|
||||
.Where(item => item.Value.Deadline <= now)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
.ToArray();
|
||||
_expiryChurn += expiredIdempotency.Length;
|
||||
foreach (string key in expiredIdempotency)
|
||||
{
|
||||
_idempotency.Remove(key);
|
||||
}
|
||||
|
||||
foreach (MediationHandle handle in _presence
|
||||
MediationHandle[] expiredPresence = _presence
|
||||
.Where(item => item.Value.Deadline <= now)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
.ToArray();
|
||||
_expiryChurn += expiredPresence.Length;
|
||||
foreach (MediationHandle handle in expiredPresence)
|
||||
{
|
||||
_presence.Remove(handle);
|
||||
}
|
||||
|
||||
foreach (JoinAttemptId attemptId in _attempts
|
||||
JoinAttemptId[] expiredAttempts = _attempts
|
||||
.Where(item => item.Value.Deadline <= now)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
.ToArray();
|
||||
_expiryChurn += expiredAttempts.Length;
|
||||
foreach (JoinAttemptId attemptId in expiredAttempts)
|
||||
{
|
||||
RemoveAttempt(attemptId);
|
||||
}
|
||||
|
||||
foreach (SessionListingId listingId in _listings
|
||||
JoinAttemptId[] expiredOutcomes = _outcomeReports
|
||||
.Where(item => item.Value.Deadline <= now)
|
||||
.Select(static item => item.Key)
|
||||
.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())
|
||||
.ToArray();
|
||||
_expiryChurn += expiredListings.Length;
|
||||
foreach (SessionListingId listingId in expiredListings)
|
||||
{
|
||||
RemoveListing(listingId);
|
||||
}
|
||||
@@ -808,6 +958,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
_presenceHandles.Clear();
|
||||
_presence.Clear();
|
||||
_attempts.Clear();
|
||||
_outcomeReports.Clear();
|
||||
_attemptHandles.Clear();
|
||||
_idempotency.Clear();
|
||||
_replay.Clear();
|
||||
@@ -830,6 +981,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
{
|
||||
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)
|
||||
@@ -868,23 +1028,29 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint,
|
||||
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
|
||||
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
|
||||
DedicatedFallback = StoredListing.CopyEndpoint(entry.Command.DedicatedFallback),
|
||||
CreatedAtMonotonic = entry.CreatedAtMonotonic,
|
||||
ExpiresAt = entry.WallExpiresAt,
|
||||
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
|
||||
HostEndpoint = entry.HostEndpoint,
|
||||
ClientEndpoint = entry.ClientEndpoint,
|
||||
IntroductionConsumed = entry.IntroductionConsumed,
|
||||
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)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray())
|
||||
.ToArray();
|
||||
foreach (string key in expired)
|
||||
{
|
||||
entries.Remove(key);
|
||||
}
|
||||
|
||||
return expired.Length;
|
||||
}
|
||||
|
||||
private static void ValidateListing(ListingDefinition listing)
|
||||
@@ -906,6 +1072,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|| listing.CurrentPlayers < 0
|
||||
|| listing.CurrentPlayers > listing.MaximumPlayers
|
||||
|| !ContractValidation.IsMetadataValid(listing.Metadata)
|
||||
|| listing.DedicatedFallback is not null
|
||||
&& !ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback)
|
||||
|| !listing.LeaseFingerprint.IsValid
|
||||
|| !listing.HostPresenceFingerprint.IsValid
|
||||
|| !IsDerivationSaltValid(listing.CapabilityDerivationSalt))
|
||||
@@ -946,6 +1114,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|| !command.HostCapabilityFingerprint.IsValid
|
||||
|| !command.ClientCapabilityFingerprint.IsValid
|
||||
|| !command.ConnectionTicketFingerprint.IsValid
|
||||
|| command.DedicatedFallback is not null
|
||||
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback)
|
||||
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|
||||
|| command.ScopeAttemptLimit <= 0)
|
||||
{
|
||||
@@ -1005,10 +1175,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|
||||
private sealed class AttemptEntry(
|
||||
CreateJoinAttemptCommand command,
|
||||
TimeSpan createdAtMonotonic,
|
||||
TimeSpan deadline,
|
||||
DateTimeOffset wallExpiresAt)
|
||||
{
|
||||
public CreateJoinAttemptCommand Command { get; } = command;
|
||||
public TimeSpan CreatedAtMonotonic { get; } = createdAtMonotonic;
|
||||
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
|
||||
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
|
||||
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
|
||||
@@ -1020,6 +1192,20 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
||||
public bool IntroductionConsumed { 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(
|
||||
@@ -1027,3 +1213,16 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
object ResourceId,
|
||||
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.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
|
||||
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
|
||||
StoreResultCode.StaleHost => RendezvousErrorCode.StaleHost,
|
||||
StoreResultCode.IncompatibleProtocol => RendezvousErrorCode.IncompatibleProtocol,
|
||||
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
_ => RendezvousErrorCode.InternalError,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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;
|
||||
|
||||
@@ -17,7 +20,7 @@ internal sealed record NatIntroductionPlan(
|
||||
IPEndPoint HostPublic,
|
||||
IPEndPoint ClientLocal,
|
||||
IPEndPoint ClientPublic,
|
||||
string ConnectionTicket)
|
||||
string IntroductionToken)
|
||||
{
|
||||
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
|
||||
}
|
||||
@@ -36,7 +39,10 @@ internal enum NatMediationResult
|
||||
internal sealed class NatMediationProcessor(
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities,
|
||||
JoinAttemptService joinAttempts)
|
||||
JoinAttemptService joinAttempts,
|
||||
AbuseProtectionService? abuseProtection = null,
|
||||
RendezvousTelemetry? telemetry = null,
|
||||
IMonotonicClock? monotonicClock = null)
|
||||
{
|
||||
public NatMediationResult ProcessDatagram(
|
||||
ReadOnlySpan<byte> encoded,
|
||||
@@ -44,6 +50,49 @@ internal sealed class NatMediationProcessor(
|
||||
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
|
||||
@@ -63,7 +112,7 @@ internal sealed class NatMediationProcessor(
|
||||
return NatMediationResult.Dropped;
|
||||
}
|
||||
|
||||
NatMediationResult result = ProcessRequest(
|
||||
NatMediationResult result = ProcessRequestCore(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
|
||||
@@ -76,7 +125,7 @@ internal sealed class NatMediationProcessor(
|
||||
return result;
|
||||
}
|
||||
|
||||
return ProcessRequest(
|
||||
return ProcessRequestCore(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
@@ -98,6 +147,49 @@ internal sealed class NatMediationProcessor(
|
||||
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)
|
||||
@@ -106,6 +198,17 @@ internal sealed class NatMediationProcessor(
|
||||
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,
|
||||
@@ -185,7 +288,15 @@ internal sealed class NatMediationProcessor(
|
||||
|
||||
try
|
||||
{
|
||||
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value.Ticket));
|
||||
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
|
||||
@@ -198,7 +309,7 @@ internal sealed class NatMediationProcessor(
|
||||
|
||||
private static NatIntroductionPlan CreatePlan(
|
||||
IntroductionEndpoints endpoints,
|
||||
string connectionTicket)
|
||||
ConnectionTicketGrant ticket)
|
||||
{
|
||||
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
|
||||
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
|
||||
@@ -209,7 +320,12 @@ internal sealed class NatMediationProcessor(
|
||||
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
|
||||
? ToIpEndpoint(clientCandidate)
|
||||
: clientPublic;
|
||||
return new(hostLocal, hostPublic, clientLocal, clientPublic, connectionTicket);
|
||||
return new(
|
||||
hostLocal,
|
||||
hostPublic,
|
||||
clientLocal,
|
||||
clientPublic,
|
||||
ticket.Ticket);
|
||||
}
|
||||
|
||||
private static bool TryCreateObservedEndpoint(
|
||||
|
||||
@@ -155,7 +155,7 @@ internal sealed partial class UdpMediatorService : BackgroundService
|
||||
plan.HostPublic,
|
||||
plan.ClientLocal,
|
||||
plan.ClientPublic,
|
||||
plan.ConnectionTicket);
|
||||
plan.IntroductionToken);
|
||||
}
|
||||
|
||||
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
|
||||
@@ -172,12 +172,21 @@ internal sealed partial class UdpMediatorService : BackgroundService
|
||||
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.ProcessDatagram(data.AsSpan(0, length), endPoint, sink);
|
||||
_ = processor.ProcessDatagramAfterIngress(
|
||||
data.AsSpan(0, length), endPoint, sink);
|
||||
}
|
||||
}
|
||||
else if (sink is not null
|
||||
@@ -188,7 +197,8 @@ internal sealed partial class UdpMediatorService : BackgroundService
|
||||
&& claimedLocalEndpoint is not null
|
||||
&& token is not null)
|
||||
{
|
||||
_ = processor.ProcessRequest(claimedLocalEndpoint, endPoint, token, sink);
|
||||
_ = processor.ProcessRequestAfterIngress(
|
||||
claimedLocalEndpoint, endPoint, token, sink);
|
||||
}
|
||||
|
||||
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"Rendezvous": {
|
||||
"AbuseProtection": {
|
||||
"OperatorAllowedAddresses": ["127.0.0.1", "::1"]
|
||||
},
|
||||
"Provisioning": {
|
||||
"Issuer": "final-factory-rendezvous-development",
|
||||
"Audience": "final-factory-rendezvous",
|
||||
@@ -14,6 +17,14 @@
|
||||
"NotBefore": "2025-01-01T00:00:00Z",
|
||||
"SignUntil": "2035-01-01T00: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": [
|
||||
|
||||
@@ -5,6 +5,47 @@
|
||||
"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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap entry point for the public-SDK-only diagnostic client.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the bootstrap diagnostic.
|
||||
/// </summary>
|
||||
public static int Main()
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
Console.WriteLine("Rendezvous TestClient bootstrap is ready.");
|
||||
return 0;
|
||||
using CancellationTokenSource shutdown = new();
|
||||
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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SilentServiceIsBoundedByTheConfiguredRequestTimeout()
|
||||
{
|
||||
using HttpClient httpClient = new(new SilentHandler())
|
||||
{
|
||||
BaseAddress = new("http://rendezvous.test/"),
|
||||
};
|
||||
RendezvousSessionBrowserClient browser = new(
|
||||
httpClient,
|
||||
new RendezvousClientOptions
|
||||
{
|
||||
MaximumSafeRetries = 0,
|
||||
RequestTimeout = TimeSpan.FromMilliseconds(20),
|
||||
JitterRatio = 0,
|
||||
});
|
||||
|
||||
RendezvousClientResult<BrowseSessionsResponse> result = await browser.BrowseAsync(new()
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
}).WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessResultRequiresAValue()
|
||||
{
|
||||
@@ -339,4 +365,15 @@ public sealed class RendezvousClientBehaviorTests
|
||||
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.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
@@ -159,6 +160,8 @@ public sealed class RendezvousClientIntegrationTests
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
builder.Services.AddOptions<AbuseProtectionOptions>();
|
||||
builder.Services.AddSingleton<AbuseProtectionService>();
|
||||
builder.Services.AddSingleton(provisioning);
|
||||
builder.Services.AddSingleton(provisioning.Credentials);
|
||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||
@@ -173,6 +176,7 @@ public sealed class RendezvousClientIntegrationTests
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
await app.StartAsync();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.ConnectionOutcomes;
|
||||
|
||||
public sealed class ConnectionOutcomeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReportRemainsAuthenticatedAfterAttemptExpiryAndCountsOnlyOnce()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||
ConnectionOutcomeMetrics metrics = new();
|
||||
ConnectionOutcomeService service = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
metrics);
|
||||
ReportConnectionOutcomeRequest report = new()
|
||||
{
|
||||
Outcome = ConnectionOutcomeKind.PunchTimedOut,
|
||||
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||
};
|
||||
fixture.Sessions.Clock.Advance(fixture.Sessions.StoreOptions.JoinAttemptLifetime);
|
||||
|
||||
ConnectionOutcomeServiceResult first = service.Report(
|
||||
attempt.AttemptId,
|
||||
attempt.ClientPunchCapability,
|
||||
report);
|
||||
ConnectionOutcomeServiceResult duplicate = service.Report(
|
||||
attempt.AttemptId,
|
||||
attempt.ClientPunchCapability,
|
||||
report);
|
||||
ConnectionOutcomeServiceResult conflict = service.Report(
|
||||
attempt.AttemptId,
|
||||
attempt.ClientPunchCapability,
|
||||
new ReportConnectionOutcomeRequest
|
||||
{
|
||||
Outcome = ConnectionOutcomeKind.Connected,
|
||||
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||
});
|
||||
|
||||
Assert.True(first.Succeeded);
|
||||
Assert.False(first.Value!.IsDuplicate);
|
||||
Assert.True(duplicate.Succeeded);
|
||||
Assert.True(duplicate.Value!.IsDuplicate);
|
||||
Assert.Equal(RendezvousErrorCode.ReplayRejected, conflict.Error);
|
||||
Assert.Equal(
|
||||
1,
|
||||
metrics.GetCount(
|
||||
ConnectionOutcomeKind.PunchTimedOut,
|
||||
ConnectionElapsedBucket.FiveToFifteenSeconds));
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.NotFound,
|
||||
service.Report(
|
||||
attempt.AttemptId,
|
||||
new string('X', ContractLimits.DerivedCredentialCharacters),
|
||||
report).Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ConnectionOutcomeKind.DirectoryNotFound)]
|
||||
[InlineData(ConnectionOutcomeKind.IncompatibleProtocol)]
|
||||
[InlineData(ConnectionOutcomeKind.Unauthorized)]
|
||||
[InlineData(ConnectionOutcomeKind.RateLimited)]
|
||||
public void ReportRejectsOutcomesThatCouldNotHaveAnIssuedAttempt(
|
||||
ConnectionOutcomeKind outcome)
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||
ConnectionOutcomeService service = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
new ConnectionOutcomeMetrics());
|
||||
|
||||
ConnectionOutcomeServiceResult result = service.Report(
|
||||
attempt.AttemptId,
|
||||
attempt.ClientPunchCapability,
|
||||
new ReportConnectionOutcomeRequest
|
||||
{
|
||||
Outcome = outcome,
|
||||
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
|
||||
});
|
||||
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListingDeletionRemovesRetainedOutcomeAuthorization()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||
ConnectionOutcomeService service = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
new ConnectionOutcomeMetrics());
|
||||
|
||||
Assert.True(fixture.Sessions.Store.RevokeListing(registration.ListingId).Succeeded);
|
||||
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.NotFound,
|
||||
service.Report(
|
||||
attempt.AttemptId,
|
||||
attempt.ClientPunchCapability,
|
||||
new ReportConnectionOutcomeRequest
|
||||
{
|
||||
Outcome = ConnectionOutcomeKind.Cancelled,
|
||||
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
|
||||
}).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrincipalRevocationRemovesReportAuthorizationAfterAttemptExpiry()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||
ConnectionOutcomeService service = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
new ConnectionOutcomeMetrics());
|
||||
fixture.Sessions.Clock.Advance(fixture.Sessions.StoreOptions.JoinAttemptLifetime);
|
||||
|
||||
Assert.True(fixture.Sessions.Store.RevokePrincipal(
|
||||
fixture.ClientSubject,
|
||||
TimeSpan.FromMinutes(1)).Succeeded);
|
||||
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.NotFound,
|
||||
service.Report(
|
||||
attempt.AttemptId,
|
||||
attempt.ClientPunchCapability,
|
||||
new ReportConnectionOutcomeRequest
|
||||
{
|
||||
Outcome = ConnectionOutcomeKind.Cancelled,
|
||||
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
|
||||
}).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrozenV1ReportFieldsAreAcceptedButNormalizedBeforeRetention()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
|
||||
ConnectionOutcomeMetrics metrics = new();
|
||||
ConnectionOutcomeService service = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
metrics);
|
||||
#pragma warning disable CS0618 // Deliberately exercises the frozen legacy input surface.
|
||||
ReportConnectionOutcomeRequest legacy = new()
|
||||
{
|
||||
Outcome = ConnectionOutcomeKind.TimedOut,
|
||||
ElapsedMilliseconds = 6_000,
|
||||
DiagnosticCode = "legacy-text-is-discarded",
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
ConnectionOutcomeServiceResult result = service.Report(
|
||||
attempt.AttemptId,
|
||||
attempt.ClientPunchCapability,
|
||||
legacy);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(
|
||||
1,
|
||||
metrics.GetCount(
|
||||
ConnectionOutcomeKind.PunchTimedOut,
|
||||
ConnectionElapsedBucket.FiveToFifteenSeconds));
|
||||
}
|
||||
}
|
||||
@@ -70,10 +70,10 @@ public sealed class ContractSerializationTests
|
||||
public void UnknownEnumNamesAndNumericValuesAreRejected()
|
||||
{
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
|
||||
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}",
|
||||
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedBucket\":\"underOneSecond\"}",
|
||||
ContractJson.Options));
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
|
||||
"{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}",
|
||||
"{\"contractVersion\":1,\"outcome\":99,\"elapsedBucket\":\"underOneSecond\"}",
|
||||
ContractJson.Options));
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ public sealed class ContractSerializationTests
|
||||
[Fact]
|
||||
public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
|
||||
{
|
||||
Assert.Equal(9, ContractJson.Options.MaxDepth);
|
||||
Assert.True(ContractJson.Options.IsReadOnly);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
ContractJson.Options.WriteIndented = true);
|
||||
|
||||
@@ -11,6 +11,11 @@ public sealed class OpenApiCompatibilityTests
|
||||
"/v1/join-attempts",
|
||||
"/v1/join-attempts/{attemptId}",
|
||||
"/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/{listingId}",
|
||||
"/v1/sessions/{listingId}/join-attempts",
|
||||
@@ -22,6 +27,7 @@ public sealed class OpenApiCompatibilityTests
|
||||
"buildVersion",
|
||||
"capacity",
|
||||
"contractVersion",
|
||||
"dedicatedFallback",
|
||||
"displayName",
|
||||
"environmentId",
|
||||
"gameId",
|
||||
@@ -63,14 +69,51 @@ public sealed class OpenApiCompatibilityTests
|
||||
Assert.Equal(ExpectedListingProperties, listingProperties);
|
||||
Assert.DoesNotContain(listingProperties, static property =>
|
||||
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|
||||
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
||||
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
|
||||
.GetProperty("properties")
|
||||
.GetProperty("dedicatedFallback");
|
||||
JsonElement fallbackReference = Assert.Single(
|
||||
dedicatedFallback.GetProperty("oneOf").EnumerateArray(),
|
||||
static schema => schema.TryGetProperty("$ref", out _));
|
||||
Assert.Equal(
|
||||
"#/components/schemas/NetworkEndpoint",
|
||||
fallbackReference.GetProperty("$ref").GetString());
|
||||
|
||||
JsonElement outcomeReportProperties = schemas.GetProperty("ReportConnectionOutcomeRequest")
|
||||
.GetProperty("properties");
|
||||
Assert.True(outcomeReportProperties.TryGetProperty("elapsedBucket", out _));
|
||||
Assert.True(outcomeReportProperties.TryGetProperty("elapsedMilliseconds", out _));
|
||||
Assert.True(outcomeReportProperties.TryGetProperty("diagnosticCode", out _));
|
||||
string[] outcomeNames = schemas.GetProperty("ConnectionOutcomeKind")
|
||||
.GetProperty("enum")
|
||||
.EnumerateArray()
|
||||
.Select(static value => value.GetString()!)
|
||||
.ToArray();
|
||||
Assert.Contains("timedOut", outcomeNames);
|
||||
Assert.Contains("staleHost", outcomeNames);
|
||||
Assert.Contains("transportFailed", outcomeNames);
|
||||
Assert.Contains("punchTimedOut", outcomeNames);
|
||||
Assert.Contains("directConnectTimedOut", outcomeNames);
|
||||
Assert.Contains("transportError", outcomeNames);
|
||||
|
||||
JsonElement publisherBearer = root.GetProperty("components")
|
||||
.GetProperty("securitySchemes")
|
||||
.GetProperty("PublisherBearer");
|
||||
Assert.Equal("http", publisherBearer.GetProperty("type").GetString());
|
||||
Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString());
|
||||
JsonElement attemptCapability = root.GetProperty("components")
|
||||
.GetProperty("securitySchemes")
|
||||
.GetProperty("JoinAttemptCapability");
|
||||
Assert.Equal("apiKey", attemptCapability.GetProperty("type").GetString());
|
||||
Assert.Equal(
|
||||
"X-Rendezvous-Client-Punch-Capability",
|
||||
attemptCapability.GetProperty("name").GetString());
|
||||
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 =
|
||||
[
|
||||
("/v1/sessions", "post"),
|
||||
@@ -87,6 +130,23 @@ public sealed class OpenApiCompatibilityTests
|
||||
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")
|
||||
.GetProperty("/v1/join-attempts/{attemptId}")
|
||||
.GetProperty("delete")
|
||||
@@ -96,6 +156,18 @@ public sealed class OpenApiCompatibilityTests
|
||||
&& parameter.GetProperty("name").GetString()
|
||||
== "X-Rendezvous-Client-Punch-Capability");
|
||||
Assert.True(cancelCapability.GetProperty("required").GetBoolean());
|
||||
foreach ((string operationPath, string method) in new[]
|
||||
{
|
||||
("/v1/join-attempts/{attemptId}", "delete"),
|
||||
("/v1/join-attempts/{attemptId}/outcome", "post"),
|
||||
})
|
||||
{
|
||||
JsonElement security = root.GetProperty("paths")
|
||||
.GetProperty(operationPath)
|
||||
.GetProperty(method)
|
||||
.GetProperty("security");
|
||||
Assert.True(security[0].TryGetProperty("JoinAttemptCapability", out _));
|
||||
}
|
||||
JsonElement hostPollParameters = root.GetProperty("paths")
|
||||
.GetProperty("/v1/sessions/{listingId}/join-attempts")
|
||||
.GetProperty("get")
|
||||
@@ -104,5 +176,58 @@ public sealed class OpenApiCompatibilityTests
|
||||
parameter.GetProperty("in").GetString() == "header"
|
||||
&& parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token");
|
||||
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 FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
@@ -91,14 +93,149 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
|
||||
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
|
||||
|
||||
using HttpRequestMessage emptyPollRequest = new(
|
||||
using HttpRequestMessage cancelledPollRequest = new(
|
||||
HttpMethod.Get,
|
||||
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest);
|
||||
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||
Assert.Empty(empty.Items);
|
||||
cancelledPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||
using HttpResponseMessage cancelledPollResponse = await host.HttpClient.SendAsync(cancelledPollRequest);
|
||||
BrowseHostJoinAttemptsResponse cancelledPoll = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||
await cancelledPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||
HostJoinAttempt cancelledAttempt = Assert.Single(cancelledPoll.Items);
|
||||
Assert.Equal(created.AttemptId, cancelledAttempt.AttemptId);
|
||||
Assert.True(cancelledAttempt.IsCancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutcomeReportingIsCapabilityAuthenticatedAndIdempotentOverHttp()
|
||||
{
|
||||
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistration(),
|
||||
host.PublisherCredential));
|
||||
Assert.True(host.Capabilities.TryFingerprint(
|
||||
session.HostPresenceCapability,
|
||||
out SecretFingerprint presenceFingerprint));
|
||||
Assert.True(host.Store.BindHostPresence(new(
|
||||
session.HostPresenceHandle,
|
||||
presenceFingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||
null)).Succeeded);
|
||||
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
|
||||
"v1/join-attempts",
|
||||
new CreateJoinAttemptRequest
|
||||
{
|
||||
IdempotencyKey = "outcome-report-1",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ListingId = session.ListingId,
|
||||
ProtocolVersion = 7,
|
||||
},
|
||||
ContractJson.Options);
|
||||
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
|
||||
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
|
||||
ReportConnectionOutcomeRequest report = new()
|
||||
{
|
||||
Outcome = ConnectionOutcomeKind.PunchTimedOut,
|
||||
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||
};
|
||||
|
||||
ReportConnectionOutcomeResponse first = await SendOutcomeAsync(
|
||||
host.HttpClient,
|
||||
created,
|
||||
report);
|
||||
ReportConnectionOutcomeResponse duplicate = await SendOutcomeAsync(
|
||||
host.HttpClient,
|
||||
created,
|
||||
report);
|
||||
|
||||
Assert.True(first.Accepted);
|
||||
Assert.False(first.IsDuplicate);
|
||||
Assert.True(duplicate.Accepted);
|
||||
Assert.True(duplicate.IsDuplicate);
|
||||
Assert.Equal(
|
||||
1,
|
||||
host.OutcomeMetrics.GetCount(
|
||||
ConnectionOutcomeKind.PunchTimedOut,
|
||||
ConnectionElapsedBucket.FiveToFifteenSeconds));
|
||||
|
||||
using HttpRequestMessage conflictRequest = OutcomeRequest(
|
||||
created,
|
||||
new ReportConnectionOutcomeRequest
|
||||
{
|
||||
Outcome = ConnectionOutcomeKind.Connected,
|
||||
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||
});
|
||||
using HttpResponseMessage conflict = await host.HttpClient.SendAsync(conflictRequest);
|
||||
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
|
||||
|
||||
using HttpRequestMessage unauthorizedRequest = OutcomeRequest(created, report);
|
||||
unauthorizedRequest.Headers.Remove("X-Rendezvous-Client-Punch-Capability");
|
||||
unauthorizedRequest.Headers.Add(
|
||||
"X-Rendezvous-Client-Punch-Capability",
|
||||
new string('X', ContractLimits.DerivedCredentialCharacters));
|
||||
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedRequest);
|
||||
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7u, HttpStatusCode.Gone, RendezvousErrorCode.StaleHost)]
|
||||
[InlineData(8u, HttpStatusCode.Conflict, RendezvousErrorCode.IncompatibleProtocol)]
|
||||
public async Task JoinCreationPreservesTypedTerminalErrorsOverHttp(
|
||||
uint protocolVersion,
|
||||
HttpStatusCode expectedStatus,
|
||||
RendezvousErrorCode expectedError)
|
||||
{
|
||||
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistration(),
|
||||
host.PublisherCredential));
|
||||
|
||||
using HttpResponseMessage response = await host.HttpClient.PostAsJsonAsync(
|
||||
"v1/join-attempts",
|
||||
new CreateJoinAttemptRequest
|
||||
{
|
||||
IdempotencyKey = $"typed-http-error-{protocolVersion}",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ListingId = session.ListingId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
},
|
||||
ContractJson.Options);
|
||||
|
||||
Assert.Equal(expectedStatus, response.StatusCode);
|
||||
ApiError error = Assert.IsType<ApiError>(
|
||||
await response.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
|
||||
Assert.Equal(expectedError, error.Code);
|
||||
}
|
||||
|
||||
private static async Task<ReportConnectionOutcomeResponse> SendOutcomeAsync(
|
||||
HttpClient client,
|
||||
CreateJoinAttemptResponse attempt,
|
||||
ReportConnectionOutcomeRequest report)
|
||||
{
|
||||
using HttpRequestMessage request = OutcomeRequest(attempt, report);
|
||||
using HttpResponseMessage response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return Assert.IsType<ReportConnectionOutcomeResponse>(
|
||||
await response.Content.ReadFromJsonAsync<ReportConnectionOutcomeResponse>(ContractJson.Options));
|
||||
}
|
||||
|
||||
private static HttpRequestMessage OutcomeRequest(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
ReportConnectionOutcomeRequest report)
|
||||
{
|
||||
HttpRequestMessage request = new(
|
||||
HttpMethod.Post,
|
||||
$"v1/join-attempts/{attempt.AttemptId}/outcome")
|
||||
{
|
||||
Content = JsonContent.Create(report, options: ContractJson.Options),
|
||||
};
|
||||
request.Headers.Add(
|
||||
"X-Rendezvous-Client-Punch-Capability",
|
||||
attempt.ClientPunchCapability);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||
@@ -130,18 +267,21 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
HttpClient httpClient,
|
||||
InMemoryEphemeralRendezvousStore store,
|
||||
EphemeralCapabilityIssuer capabilities,
|
||||
ConnectionOutcomeMetrics outcomeMetrics,
|
||||
string publisherCredential)
|
||||
{
|
||||
_application = application;
|
||||
HttpClient = httpClient;
|
||||
Store = store;
|
||||
Capabilities = capabilities;
|
||||
OutcomeMetrics = outcomeMetrics;
|
||||
PublisherCredential = publisherCredential;
|
||||
}
|
||||
|
||||
internal HttpClient HttpClient { get; }
|
||||
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||
internal ConnectionOutcomeMetrics OutcomeMetrics { get; }
|
||||
internal string PublisherCredential { get; }
|
||||
|
||||
internal static async Task<JoinHttpTestHost> StartAsync()
|
||||
@@ -165,6 +305,8 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
builder.Services.AddOptions<AbuseProtectionOptions>();
|
||||
builder.Services.AddSingleton<AbuseProtectionService>();
|
||||
builder.Services.AddSingleton(provisioning);
|
||||
builder.Services.AddSingleton(provisioning.Policies);
|
||||
builder.Services.AddSingleton(provisioning.Credentials);
|
||||
@@ -179,9 +321,13 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||
builder.Services.AddSingleton<JoinAttemptService>();
|
||||
ConnectionOutcomeMetrics outcomeMetrics = new();
|
||||
builder.Services.AddSingleton(outcomeMetrics);
|
||||
builder.Services.AddSingleton<ConnectionOutcomeService>();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
await app.StartAsync();
|
||||
IServer server = app.Services.GetRequiredService<IServer>();
|
||||
@@ -191,6 +337,7 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
new HttpClient { BaseAddress = new Uri(address) },
|
||||
store,
|
||||
capabilities,
|
||||
outcomeMetrics,
|
||||
credential);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
|
||||
@@ -64,7 +66,7 @@ public sealed class JoinAttemptServiceTests
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.NotFound,
|
||||
RendezvousErrorCode.StaleHost,
|
||||
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
|
||||
|
||||
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
||||
@@ -81,6 +83,41 @@ public sealed class JoinAttemptServiceTests
|
||||
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListingProtocolMismatchRemainsDistinctWhenTheRequestedProtocolIsAllowed()
|
||||
{
|
||||
GamePolicyOptions policy = ProvisioningTestData.CreatePolicy();
|
||||
policy.ProtocolVersions.Add(8);
|
||||
using JoinAttemptFixture fixture = new(joinPolicy: policy);
|
||||
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptRequest request = fixture.Request(active.ListingId);
|
||||
request.ProtocolVersion = 8;
|
||||
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.IncompatibleProtocol,
|
||||
fixture.Service.Create(fixture.ClientSubject, request).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IssuedAttemptCarriesTheHostsDedicatedFallbackCandidate()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
RegisterSessionRequest registrationRequest = fixture.Sessions.Request();
|
||||
registrationRequest.DedicatedFallback = new()
|
||||
{
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
Address = "203.0.113.91",
|
||||
Port = 9_061,
|
||||
};
|
||||
RegisterSessionResponse registration = fixture.Sessions.Register(registrationRequest);
|
||||
Assert.True(fixture.Sessions.BindPresence(registration).Succeeded);
|
||||
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||
|
||||
Assert.Equal("203.0.113.91", created.DedicatedFallback!.Address);
|
||||
Assert.Equal(9_061, created.DedicatedFallback.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
|
||||
{
|
||||
@@ -152,12 +189,47 @@ public sealed class JoinAttemptServiceTests
|
||||
Assert.True(fixture.Service.Cancel(
|
||||
created.AttemptId,
|
||||
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,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
10,
|
||||
null).Value!.Items);
|
||||
Assert.Equal(created.AttemptId, cancelled.AttemptId);
|
||||
Assert.True(cancelled.IsCancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationAfterIntroductionRevokesTicketIssuanceAndConsumption()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||
JoinAttemptServiceResult<ConnectionTicketGrant> issued =
|
||||
fixture.Service.IssueConnectionTicket(introduction.Attempt);
|
||||
Assert.True(issued.Succeeded);
|
||||
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||
issued.Value!.Ticket,
|
||||
out SecretFingerprint ticketFingerprint));
|
||||
|
||||
Assert.True(fixture.Service.Cancel(
|
||||
created.AttemptId,
|
||||
created.ClientPunchCapability).Succeeded);
|
||||
|
||||
StoredJoinAttempt cancelled = fixture.GetAttempt(registration, created.AttemptId);
|
||||
Assert.True(cancelled.IsCancelled);
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.Conflict,
|
||||
fixture.Service.IssueConnectionTicket(cancelled).Error);
|
||||
Assert.Equal(
|
||||
StoreResultCode.Conflict,
|
||||
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||
created.AttemptId,
|
||||
ticketFingerprint)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -12,11 +12,15 @@ internal sealed class JoinAttemptFixture : IDisposable
|
||||
{
|
||||
private int _sequence;
|
||||
|
||||
public JoinAttemptFixture(EphemeralStoreOptions? options = null)
|
||||
public JoinAttemptFixture(
|
||||
EphemeralStoreOptions? options = null,
|
||||
GamePolicyOptions? joinPolicy = null)
|
||||
{
|
||||
Sessions = new(options);
|
||||
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);
|
||||
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,
|
||||
service.Validate(tampered, ProvisioningTestData.Now).Error);
|
||||
Assert.True(keys.Revoke("key-1"));
|
||||
Assert.True(keys.Revoke("key-1"));
|
||||
Assert.Equal(
|
||||
CredentialValidationError.KeyRevoked,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,56 @@
|
||||
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()
|
||||
{
|
||||
@@ -56,11 +97,16 @@ public sealed class NatMediationProcessorTests
|
||||
Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal);
|
||||
Assert.Equal(Endpoint("203.0.113.20", 51_000), plan.HostPublic);
|
||||
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
|
||||
Assert.Equal(43, plan.ConnectionTicket.Length);
|
||||
Assert.DoesNotContain(plan.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
|
||||
Assert.True(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(
|
||||
plan.ConnectionTicket,
|
||||
introduction.ConnectionTicket,
|
||||
out SecretFingerprint ticketFingerprint));
|
||||
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||
attempt.AttemptId,
|
||||
@@ -187,7 +233,7 @@ public sealed class NatMediationProcessorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationCannotReportSuccessAfterIntroductionIsConsumed()
|
||||
public async Task CancellationAfterIntroductionCreatesAHostRevocationTombstone()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
@@ -209,9 +255,16 @@ public sealed class NatMediationProcessorTests
|
||||
attempt.AttemptId,
|
||||
attempt.ClientCapability);
|
||||
|
||||
Assert.Equal(RendezvousErrorCode.Conflict, cancelled.Error);
|
||||
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]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
using LiteNetLib;
|
||||
@@ -86,8 +87,10 @@ public sealed class UdpMediatorServiceTests
|
||||
await service.StopAsync(timeout.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeLiteNetLibRequestsIntroduceTheAuthorizedPair()
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task NativeLiteNetLibRequestsIntroduceTheAuthorizedPair(bool restartMediator)
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using JoinAttemptFixture fixture = new();
|
||||
@@ -103,18 +106,6 @@ public sealed class UdpMediatorServiceTests
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
MaxDatagramsPerPoll = 8,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
await service.StartAsync(timeout.Token);
|
||||
|
||||
EventBasedNetListener hostListener = new();
|
||||
EventBasedNetListener clientListener = new();
|
||||
NetManager host = new(hostListener) { NatPunchEnabled = true };
|
||||
@@ -127,11 +118,90 @@ public sealed class UdpMediatorServiceTests
|
||||
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,
|
||||
@@ -146,28 +216,42 @@ public sealed class UdpMediatorServiceTests
|
||||
created.MediationHandle,
|
||||
created.ClientPunchCapability));
|
||||
|
||||
while ((hostTickets.Count == 0 || clientTickets.Count == 0)
|
||||
&& !timeout.IsCancellationRequested)
|
||||
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, timeout.Token);
|
||||
await Task.Delay(5, cancellationToken);
|
||||
}
|
||||
|
||||
string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal));
|
||||
string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal));
|
||||
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.Equal(43, hostTicket.Length);
|
||||
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||
hostTicket,
|
||||
out NatIntroductionToken? introduction));
|
||||
Assert.NotNull(introduction);
|
||||
Assert.Equal(created.AttemptId, introduction.AttemptId);
|
||||
Assert.Equal(43, introduction.ConnectionTicket.Length);
|
||||
}
|
||||
finally
|
||||
|
||||
private static UdpMediatorService CreateMediator(NatMediationProcessor processor, int port) => new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
host.Stop();
|
||||
client.Stop();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = port,
|
||||
MaxDatagramsPerPoll = 8,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
|
||||
[Fact]
|
||||
public async Task FrozenV1EnvelopeIsConsumedOnTheLiteNetSocketWithinAmplificationBudget()
|
||||
@@ -229,7 +313,9 @@ public sealed class UdpMediatorServiceTests
|
||||
Assert.True(
|
||||
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
|
||||
<= clientDatagram.Length * 2,
|
||||
"The completing authenticated contribution exceeded the 2.0 response-byte budget.");
|
||||
$"The completing authenticated contribution exceeded the 2.0 response-byte budget: "
|
||||
+ $"responses={hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length}, "
|
||||
+ $"request={clientDatagram.Length}.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -242,10 +328,12 @@ public sealed class UdpMediatorServiceTests
|
||||
{
|
||||
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);
|
||||
fixture.Service,
|
||||
protection);
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
@@ -273,6 +361,7 @@ public sealed class UdpMediatorServiceTests
|
||||
using CancellationTokenSource noResponse = new(TimeSpan.FromMilliseconds(150));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
await sender.ReceiveAsync(noResponse.Token));
|
||||
Assert.InRange(protection.TrackedKeyCount, 3, 5);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
@@ -44,6 +45,8 @@ public sealed class SessionHttpEndpointTests
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
builder.Services.AddOptions<AbuseProtectionOptions>();
|
||||
builder.Services.AddSingleton<AbuseProtectionService>();
|
||||
builder.Services.AddSingleton(provisioning);
|
||||
builder.Services.AddSingleton(provisioning.Credentials);
|
||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||
@@ -57,6 +60,7 @@ public sealed class SessionHttpEndpointTests
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
await using WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
await app.StartAsync();
|
||||
IServer server = app.Services.GetRequiredService<IServer>();
|
||||
|
||||
@@ -3,6 +3,7 @@ using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||
|
||||
@@ -118,6 +119,12 @@ public sealed class SessionLeaseServiceTests
|
||||
["mode"] = "co-op",
|
||||
["map"] = "europa",
|
||||
},
|
||||
DedicatedFallback = new()
|
||||
{
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
Address = "203.0.113.92",
|
||||
Port = 9_062,
|
||||
},
|
||||
});
|
||||
StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!;
|
||||
|
||||
@@ -128,6 +135,7 @@ public sealed class SessionLeaseServiceTests
|
||||
Assert.Equal(fixture.Scope, stored.Definition.Scope);
|
||||
Assert.Equal(8, stored.Definition.CurrentPlayers);
|
||||
Assert.Equal(8, stored.Definition.MaximumPlayers);
|
||||
Assert.Equal("203.0.113.92", stored.Definition.DedicatedFallback!.Address);
|
||||
Assert.True(fixture.Service.Delete(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
@@ -139,6 +147,45 @@ public sealed class SessionLeaseServiceTests
|
||||
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisabledFallbackPolicyRejectsRegistrationAndUpdateEndpoints()
|
||||
{
|
||||
GamePolicyOptions policy = ProvisioningTestData.CreatePolicy();
|
||||
policy.FallbackPolicy = FallbackPolicyMode.Disabled;
|
||||
using SessionLeaseFixture fixture = new(policyOptions: policy);
|
||||
RegisterSessionRequest registrationRequest = fixture.Request();
|
||||
registrationRequest.DedicatedFallback = new()
|
||||
{
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
Address = "203.0.113.94",
|
||||
Port = 9_064,
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.Forbidden,
|
||||
fixture.Service.Register(fixture.Principal, registrationRequest).Error);
|
||||
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.Forbidden,
|
||||
fixture.Service.Update(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new UpdateSessionRequest
|
||||
{
|
||||
LeaseToken = registration.LeaseToken,
|
||||
BuildVersion = "1.4.3",
|
||||
DisplayName = "Europa Updated",
|
||||
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["mode"] = "co-op",
|
||||
["map"] = "europa",
|
||||
},
|
||||
DedicatedFallback = registrationRequest.DedicatedFallback,
|
||||
}).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnotherPublisherCannotRenewUpdateOrDeleteListing()
|
||||
{
|
||||
|
||||
@@ -11,13 +11,17 @@ internal sealed class SessionLeaseFixture : IDisposable
|
||||
{
|
||||
private int _sequence;
|
||||
|
||||
public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null)
|
||||
public SessionLeaseFixture(
|
||||
EphemeralStoreOptions? storeOptions = null,
|
||||
GamePolicyOptions? policyOptions = null)
|
||||
{
|
||||
StoreOptions = storeOptions ?? new EphemeralStoreOptions();
|
||||
Clock = new();
|
||||
Store = new(StoreOptions, Clock, Clock);
|
||||
Capabilities = new();
|
||||
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
|
||||
GamePolicyRegistry policies = GamePolicyRegistry.Create([
|
||||
policyOptions ?? ProvisioningTestData.CreatePolicy(),
|
||||
]);
|
||||
Service = new(
|
||||
new PublisherAuthorizationService(policies),
|
||||
Store,
|
||||
|
||||
@@ -5,6 +5,57 @@ namespace FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
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]
|
||||
public void IdempotencyRetentionMustCoverResourceLifetimes()
|
||||
{
|
||||
@@ -134,13 +185,13 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
|
||||
StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!;
|
||||
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(
|
||||
listingCommand.Listing.HostPresenceHandle,
|
||||
listingCommand.Listing.HostPresenceFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(40_000),
|
||||
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
|
||||
{
|
||||
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));
|
||||
|
||||
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.BindAttemptEndpoint(new(
|
||||
attempt.MediationHandle,
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed class StoreResultMappingTests
|
||||
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
|
||||
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
|
||||
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
|
||||
[StoreResultCode.StaleHost] = RendezvousErrorCode.StaleHost,
|
||||
[StoreResultCode.IncompatibleProtocol] = RendezvousErrorCode.IncompatibleProtocol,
|
||||
};
|
||||
|
||||
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using FinalFactory.Rendezvous.TestClient;
|
||||
using LiteNetLib;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.TestClient;
|
||||
|
||||
public sealed class DirectEchoProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HostReleasesPendingNonceWhenPeerDisconnectsBeforeAcknowledgement()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
|
||||
EventBasedNetListener hostEvents = new();
|
||||
EventBasedNetListener clientEvents = new();
|
||||
hostEvents.ConnectionRequestEvent += request => request.Accept();
|
||||
NetPeer? clientPeer = null;
|
||||
clientEvents.PeerConnectedEvent += peer => clientPeer = peer;
|
||||
NetManager hostManager = new(hostEvents);
|
||||
NetManager clientManager = new(clientEvents);
|
||||
try
|
||||
{
|
||||
Assert.True(hostManager.Start(0));
|
||||
Assert.True(clientManager.Start(0));
|
||||
clientManager.Connect(
|
||||
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||
"echo-test");
|
||||
await PumpUntilAsync(
|
||||
() => clientPeer is not null,
|
||||
hostManager,
|
||||
clientManager,
|
||||
clientManager,
|
||||
timeout.Token);
|
||||
using DirectEchoProtocol host = new(hostEvents, host: true);
|
||||
clientPeer!.Send(
|
||||
Encoding.ASCII.GetBytes("rv1-ping:00112233445566778899aabbccddeeff"),
|
||||
DeliveryMethod.ReliableOrdered);
|
||||
await PumpUntilAsync(
|
||||
() => host.PendingHostExchangeCount == 1,
|
||||
hostManager,
|
||||
clientManager,
|
||||
clientManager,
|
||||
timeout.Token);
|
||||
|
||||
clientPeer.Disconnect();
|
||||
await PumpUntilAsync(
|
||||
() => host.PendingHostExchangeCount == 0,
|
||||
hostManager,
|
||||
clientManager,
|
||||
clientManager,
|
||||
timeout.Token);
|
||||
|
||||
Assert.Equal(0, host.PendingHostExchangeCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
clientManager.Stop();
|
||||
hostManager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostVerifiesOverlappingPeersAgainstTheirOwnNonces()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
|
||||
EventBasedNetListener hostEvents = new();
|
||||
EventBasedNetListener firstEvents = new();
|
||||
EventBasedNetListener secondEvents = new();
|
||||
hostEvents.ConnectionRequestEvent += request => request.Accept();
|
||||
NetPeer? firstPeer = null;
|
||||
NetPeer? secondPeer = null;
|
||||
firstEvents.PeerConnectedEvent += peer => firstPeer = peer;
|
||||
secondEvents.PeerConnectedEvent += peer => secondPeer = peer;
|
||||
NetManager hostManager = new(hostEvents);
|
||||
NetManager firstManager = new(firstEvents);
|
||||
NetManager secondManager = new(secondEvents);
|
||||
try
|
||||
{
|
||||
Assert.True(hostManager.Start(0));
|
||||
Assert.True(firstManager.Start(0));
|
||||
Assert.True(secondManager.Start(0));
|
||||
IPEndPoint hostEndpoint = new(IPAddress.Loopback, hostManager.LocalPort);
|
||||
firstManager.Connect(hostEndpoint, "echo-test");
|
||||
secondManager.Connect(hostEndpoint, "echo-test");
|
||||
await PumpUntilAsync(
|
||||
() => firstPeer is not null && secondPeer is not null,
|
||||
hostManager,
|
||||
firstManager,
|
||||
secondManager,
|
||||
timeout.Token);
|
||||
|
||||
using DirectEchoProtocol host = new(hostEvents, host: true);
|
||||
using DirectEchoProtocol first = new(firstEvents, host: false);
|
||||
using DirectEchoProtocol second = new(secondEvents, host: false);
|
||||
int hostCompletions = 0;
|
||||
host.ExchangeCompleted += _ => hostCompletions++;
|
||||
first.BeginJoin(firstPeer!);
|
||||
second.BeginJoin(secondPeer!);
|
||||
|
||||
await PumpUntilAsync(
|
||||
() => first.Completion.IsCompleted
|
||||
&& second.Completion.IsCompleted
|
||||
&& hostCompletions == 2,
|
||||
hostManager,
|
||||
firstManager,
|
||||
secondManager,
|
||||
timeout.Token);
|
||||
|
||||
Assert.Equal(2, hostCompletions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
firstManager.Stop();
|
||||
secondManager.Stop();
|
||||
hostManager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PumpUntilAsync(
|
||||
Func<bool> predicate,
|
||||
NetManager host,
|
||||
NetManager first,
|
||||
NetManager second,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
while (!predicate())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
host.PollEvents();
|
||||
first.PollEvents();
|
||||
second.PollEvents();
|
||||
await Task.Delay(2, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.TestClient;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.TestClient;
|
||||
|
||||
public sealed class TestClientCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostOptionsParseBoundedPublicConfigurationWithoutAcceptingASecretArgument()
|
||||
{
|
||||
TestClientParseResult parsed = TestClientOptionParser.Parse(
|
||||
[
|
||||
"host",
|
||||
"--service", "https://rendezvous.example/base",
|
||||
"--mediator", "127.0.0.1:9050",
|
||||
"--game", "space-game",
|
||||
"--environment", "production",
|
||||
"--region", "eu-central",
|
||||
"--protocol", "7",
|
||||
"--metadata", "mode=online-coop",
|
||||
"--fallback", "203.0.113.50:7777",
|
||||
"--publisher-credential-env", "TEST_PUBLISHER_CREDENTIAL",
|
||||
"--script",
|
||||
"--json",
|
||||
"--exit-after-echo",
|
||||
]);
|
||||
|
||||
Assert.True(parsed.Succeeded, parsed.Error);
|
||||
TestClientOptions options = Assert.IsType<TestClientOptions>(parsed.Options);
|
||||
Assert.Equal(TestClientMode.Host, options.Mode);
|
||||
Assert.Equal(new Uri("https://rendezvous.example/base/"), options.ServiceUri);
|
||||
Assert.Equal(7u, options.ProtocolVersion);
|
||||
Assert.Equal("online-coop", options.Metadata["mode"]);
|
||||
Assert.Equal("203.0.113.50", options.DedicatedFallback?.Address);
|
||||
Assert.Equal(7777, options.DedicatedFallback?.Port);
|
||||
Assert.Equal("TEST_PUBLISHER_CREDENTIAL", options.PublisherCredentialEnvironmentVariable);
|
||||
Assert.True(options.Script);
|
||||
Assert.True(options.Json);
|
||||
Assert.True(options.ExitAfterEcho);
|
||||
Assert.Equal(TimeSpan.FromSeconds(20), options.RunDuration);
|
||||
|
||||
TestClientParseResult secret = TestClientOptionParser.Parse(
|
||||
["host", "--publisher-credential", "secret-canary"]);
|
||||
Assert.False(secret.Succeeded);
|
||||
Assert.Contains("Unknown option", secret.Error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptExitCodesRemainStable()
|
||||
{
|
||||
Assert.Equal(0, (int)TestClientExitCode.Success);
|
||||
Assert.Equal(2, (int)TestClientExitCode.Usage);
|
||||
Assert.Equal(3, (int)TestClientExitCode.Configuration);
|
||||
Assert.Equal(10, (int)TestClientExitCode.ServiceFailure);
|
||||
Assert.Equal(11, (int)TestClientExitCode.NoCompatibleSession);
|
||||
Assert.Equal(12, (int)TestClientExitCode.TraversalFailed);
|
||||
Assert.Equal(13, (int)TestClientExitCode.DirectTrafficFailed);
|
||||
Assert.Equal(130, (int)TestClientExitCode.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
HostServiceFailureBudget authority = new();
|
||||
Assert.True(authority.ShouldStop(
|
||||
RendezvousErrorCode.NotFound,
|
||||
now.AddMinutes(1),
|
||||
now));
|
||||
|
||||
HostServiceFailureBudget transient = new();
|
||||
Assert.False(transient.ShouldStop(
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
now.AddMinutes(1),
|
||||
now));
|
||||
Assert.False(transient.ShouldStop(
|
||||
RendezvousErrorCode.RateLimited,
|
||||
now.AddMinutes(1),
|
||||
now));
|
||||
Assert.True(transient.ShouldStop(
|
||||
RendezvousErrorCode.InternalError,
|
||||
now.AddMinutes(1),
|
||||
now));
|
||||
|
||||
transient.Reset();
|
||||
Assert.True(transient.ShouldStop(
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
now,
|
||||
now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://user:password@rendezvous.example/")]
|
||||
[InlineData("file:///tmp/rendezvous")]
|
||||
[InlineData("https://rendezvous.example/?token=secret")]
|
||||
public void ServiceUrlRejectsCredentialAndNonHttpShapes(string url)
|
||||
{
|
||||
TestClientParseResult parsed = TestClientOptionParser.Parse(["browse", "--service", url]);
|
||||
|
||||
Assert.False(parsed.Succeeded);
|
||||
Assert.Contains("service URL", parsed.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplicationRoutesParsedOptionsThroughTheInjectableUiFlow()
|
||||
{
|
||||
FakeCommandRunner runner = new(TestClientExitCode.NoCompatibleSession);
|
||||
TestClientApplication application = new(runner);
|
||||
StringWriter output = new();
|
||||
StringWriter error = new();
|
||||
|
||||
int exitCode = await application.RunAsync(
|
||||
["browse", "--script", "--json"],
|
||||
new StringReader(string.Empty),
|
||||
output,
|
||||
error,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal((int)TestClientExitCode.NoCompatibleSession, exitCode);
|
||||
Assert.NotNull(runner.Options);
|
||||
Assert.Equal(TestClientMode.Browse, runner.Options.Mode);
|
||||
Assert.True(runner.Options.Script);
|
||||
using JsonDocument item = JsonDocument.Parse(output.ToString());
|
||||
Assert.Equal(1, item.RootElement.GetProperty("version").GetInt32());
|
||||
Assert.Equal("fake.completed", item.RootElement.GetProperty("event").GetString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidArgumentsFailBeforeTheRunnerAndDoNotEchoTheValue()
|
||||
{
|
||||
FakeCommandRunner runner = new(TestClientExitCode.Success);
|
||||
TestClientApplication application = new(runner);
|
||||
StringWriter output = new();
|
||||
StringWriter error = new();
|
||||
|
||||
int exitCode = await application.RunAsync(
|
||||
["host", "--publisher-credential", "secret-canary"],
|
||||
new StringReader(string.Empty),
|
||||
output,
|
||||
error,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal((int)TestClientExitCode.Usage, exitCode);
|
||||
Assert.Null(runner.Options);
|
||||
Assert.DoesNotContain("secret-canary", error.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("host", "--json", "--unknown", "value", "cli.usage", 2)]
|
||||
[InlineData("host", "--json", "--help", "", "cli.help", 0)]
|
||||
public async Task JsonModeKeepsHelpAndUsageFailuresMachineReadable(
|
||||
string mode,
|
||||
string json,
|
||||
string option,
|
||||
string value,
|
||||
string expectedEvent,
|
||||
int expectedExit)
|
||||
{
|
||||
FakeCommandRunner runner = new(TestClientExitCode.Success);
|
||||
TestClientApplication application = new(runner);
|
||||
StringWriter output = new();
|
||||
StringWriter error = new();
|
||||
string[] args = string.IsNullOrEmpty(value)
|
||||
? [mode, json, option]
|
||||
: [mode, json, option, value];
|
||||
|
||||
int exitCode = await application.RunAsync(
|
||||
args,
|
||||
new StringReader(string.Empty),
|
||||
output,
|
||||
error,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(expectedExit, exitCode);
|
||||
string jsonLine = expectedExit == 0 ? output.ToString() : error.ToString();
|
||||
using JsonDocument item = JsonDocument.Parse(jsonLine);
|
||||
Assert.Equal(expectedEvent, item.RootElement.GetProperty("event").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HumanOutputNeutralizesControlCharactersFromPublicListingText()
|
||||
{
|
||||
StringWriter output = new();
|
||||
TestClientOutput sink = new(output, new StringWriter(), json: false);
|
||||
|
||||
sink.Write(
|
||||
"browse.session",
|
||||
"available",
|
||||
displayName: "host\nforged-line\u001b[31m outcome=connected\u2028next\u2029line\u202eright");
|
||||
|
||||
string line = output.ToString();
|
||||
Assert.Equal(1, line.Count(static character => character == '\n'));
|
||||
Assert.DoesNotContain('\u001b', line);
|
||||
Assert.DoesNotContain('\u2028', line);
|
||||
Assert.DoesNotContain('\u2029', line);
|
||||
Assert.DoesNotContain('\u202e', line);
|
||||
Assert.Contains("name=\"host?forged-line?[31m outcome=connected?next?line?right\"", line, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class FakeCommandRunner(TestClientExitCode exitCode) : ITestClientCommandRunner
|
||||
{
|
||||
internal TestClientOptions? Options { get; private set; }
|
||||
|
||||
public Task<TestClientExitCode> RunAsync(
|
||||
TestClientOptions options,
|
||||
TestClientOutput output,
|
||||
TextReader input,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Options = options;
|
||||
output.Write("fake.completed", "complete", phase: "test");
|
||||
return Task.FromResult(exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,25 @@ TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
|
||||
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
|
||||
METHOD System.String ToString()
|
||||
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
|
||||
TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequest
|
||||
CTOR ()
|
||||
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
||||
PROP System.String ConnectionTicket {get;set;}
|
||||
METHOD System.String ToString()
|
||||
TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequestCodec
|
||||
FIELD System.Int32 EncodedLength=63
|
||||
METHOD System.Byte[] Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
|
||||
METHOD System.Boolean IsRendezvousRequest(System.ReadOnlySpan<System.Byte> encoded)
|
||||
METHOD System.Boolean TryDecode(System.ReadOnlySpan<System.Byte> encoded, FinalFactory.Rendezvous.Client.DirectConnectionRequest& request)
|
||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
|
||||
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousJoinClient
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult> CreateConnectionAttemptAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse>> ReportOutcomeAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
@@ -41,12 +58,26 @@ TYPE FinalFactory.Rendezvous.Client.PublishedSession
|
||||
PROP System.String LeaseToken {get;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;}
|
||||
METHOD System.String ToString()
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientCoordinator
|
||||
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
|
||||
PROP LiteNetLib.NetPeer ConnectedPeer {get;}
|
||||
PROP System.Boolean IsCompleted {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
|
||||
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs> Completed
|
||||
METHOD System.Void Cancel()
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Void Dispose()
|
||||
METHOD System.Void Poll()
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse>> ReportOutcomeAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.String ToString()
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
|
||||
CTOR ()
|
||||
PROP System.TimeSpan InitialRetryDelay {get;set;}
|
||||
PROP System.Double JitterRatio {get;set;}
|
||||
PROP System.TimeSpan MaximumRetryDelay {get;set;}
|
||||
PROP System.Int32 MaximumSafeRetries {get;set;}
|
||||
PROP System.TimeSpan RequestTimeout {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult
|
||||
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable<System.Int32> retryAfterSeconds)
|
||||
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Success(T value)
|
||||
@@ -56,6 +87,119 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
|
||||
PROP System.String Message {get;}
|
||||
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;}
|
||||
PROP T Value {get;}
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs
|
||||
CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
|
||||
PROP LiteNetLib.NetPeer Peer {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionFailureCategory
|
||||
ENUM None=0
|
||||
ENUM Directory=1
|
||||
ENUM Compatibility=2
|
||||
ENUM Authorization=3
|
||||
ENUM Capacity=4
|
||||
ENUM HostPresence=5
|
||||
ENUM Service=6
|
||||
ENUM Mediation=7
|
||||
ENUM NatTraversal=8
|
||||
ENUM DirectConnection=9
|
||||
ENUM Lifecycle=10
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionFailureCategory Category {get;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;}
|
||||
PROP System.TimeSpan Elapsed {get;}
|
||||
PROP System.Boolean HasDedicatedFallback {get;}
|
||||
PROP System.Boolean IsSuccess {get;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Kind {get;}
|
||||
PROP LiteNetLib.NetPeer Peer {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionPhase Phase {get;}
|
||||
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.RendezvousErrorCode> ServiceError {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcomeSource Source {get;}
|
||||
METHOD FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket BucketElapsed(System.TimeSpan elapsed)
|
||||
METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome FromServiceError(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.TimeSpan elapsed, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback)
|
||||
METHOD System.String ToString()
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionOutcomeSource
|
||||
ENUM RendezvousService=1
|
||||
ENUM LocalTraversal=2
|
||||
ENUM RemoteHost=3
|
||||
ENUM Caller=4
|
||||
ENUM Lifecycle=5
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionPhase
|
||||
ENUM Directory=1
|
||||
ENUM Authorization=2
|
||||
ENUM Mediation=3
|
||||
ENUM NatTraversal=4
|
||||
ENUM DirectConnection=5
|
||||
ENUM Complete=6
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult
|
||||
PROP FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse Attempt {get;}
|
||||
PROP System.Boolean IsCompleted {get;}
|
||||
PROP System.Boolean IsReadyForTraversal {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
|
||||
METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult Completed(FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome)
|
||||
METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult ReadyForTraversal(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt)
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState
|
||||
ENUM Punching=1
|
||||
ENUM Connecting=2
|
||||
ENUM Connected=3
|
||||
ENUM Cancelled=4
|
||||
ENUM TimedOut=5
|
||||
ENUM Rejected=6
|
||||
ENUM ManagerStopped=7
|
||||
ENUM Disposed=8
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions
|
||||
CTOR ()
|
||||
PROP System.TimeSpan ConnectionTicketLifetime {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallbackOverride {get;set;}
|
||||
PROP System.TimeSpan DirectConnectTimeout {get;set;}
|
||||
PROP System.TimeSpan InitialPunchRetryDelay {get;set;}
|
||||
PROP System.Double JitterRatio {get;set;}
|
||||
PROP System.Int32 MaximumAttemptChecksPerPoll {get;set;}
|
||||
PROP System.Int32 MaximumPunchRequests {get;set;}
|
||||
PROP System.TimeSpan MaximumPunchRetryDelay {get;set;}
|
||||
PROP System.TimeSpan PunchTimeout {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs
|
||||
CTOR (FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
|
||||
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
|
||||
PROP LiteNetLib.NetPeer Peer {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousHostCoordinator
|
||||
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
|
||||
PROP System.Int32 PendingAttemptCount {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.RendezvousHostState State {get;}
|
||||
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs> AttemptCompleted
|
||||
METHOD System.Void Dispose()
|
||||
METHOD System.Void Poll()
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Int32>> RefreshJoinAttemptsAsync(System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.String ToString()
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousHostState
|
||||
ENUM Active=1
|
||||
ENUM ManagerStopped=2
|
||||
ENUM Disposed=3
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousJoinClient
|
||||
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult> CreateConnectionAttemptAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse>> ReportOutcomeAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousNetListener
|
||||
CTOR ()
|
||||
PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;}
|
||||
PROP LiteNetLib.EventBasedNatPunchListener PunchEvents {get;}
|
||||
METHOD LiteNetLib.NetManager CreateManager()
|
||||
METHOD System.Void OnConnectionRequest(LiteNetLib.ConnectionRequest request)
|
||||
METHOD System.Void OnMessageDelivered(LiteNetLib.NetPeer peer, System.Object userData)
|
||||
METHOD System.Void OnNetworkError(System.Net.IPEndPoint endPoint, System.Net.Sockets.SocketError socketError)
|
||||
METHOD System.Void OnNetworkLatencyUpdate(LiteNetLib.NetPeer peer, System.Int32 latency)
|
||||
METHOD System.Void OnNetworkReceive(LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, System.Byte channelNumber, LiteNetLib.DeliveryMethod deliveryMethod)
|
||||
METHOD System.Void OnNetworkReceiveUnconnected(System.Net.IPEndPoint remoteEndPoint, LiteNetLib.NetPacketReader reader, LiteNetLib.UnconnectedMessageType messageType)
|
||||
METHOD System.Void OnNtpResponse(LiteNetLib.Utils.NtpPacket packet)
|
||||
METHOD System.Void OnPeerAddressChanged(LiteNetLib.NetPeer peer, System.Net.IPEndPoint previousAddress)
|
||||
METHOD System.Void OnPeerConnected(LiteNetLib.NetPeer peer)
|
||||
METHOD System.Void OnPeerDisconnected(LiteNetLib.NetPeer peer, LiteNetLib.DisconnectInfo disconnectInfo)
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient
|
||||
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||
METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential)
|
||||
|
||||
@@ -28,6 +28,12 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
|
||||
PROP System.String NextCursor {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket
|
||||
ENUM UnderOneSecond=1
|
||||
ENUM OneToFiveSeconds=2
|
||||
ENUM FiveToFifteenSeconds=3
|
||||
ENUM FifteenToThirtySeconds=4
|
||||
ENUM ThirtySecondsOrMore=5
|
||||
TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind
|
||||
ENUM Connected=1
|
||||
ENUM Cancelled=2
|
||||
@@ -38,6 +44,18 @@ TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind
|
||||
ENUM HostRejected=7
|
||||
ENUM TransportFailed=8
|
||||
ENUM FallbackOffered=9
|
||||
ENUM DirectoryNotFound=10
|
||||
ENUM AttemptExpired=11
|
||||
ENUM Unauthorized=12
|
||||
ENUM RateLimited=13
|
||||
ENUM NoHostPresence=14
|
||||
ENUM ServiceUnavailable=15
|
||||
ENUM MediatorUnavailable=16
|
||||
ENUM PunchTimedOut=17
|
||||
ENUM DirectConnectTimedOut=18
|
||||
ENUM TransportError=19
|
||||
ENUM ManagerStopped=20
|
||||
ENUM Disposed=21
|
||||
TYPE FinalFactory.Rendezvous.Contracts.ContractJson
|
||||
PROP System.Text.Json.JsonSerializerOptions Options {get;}
|
||||
METHOD System.Void Configure(System.Text.Json.JsonSerializerOptions options)
|
||||
@@ -84,6 +102,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractValidation
|
||||
METHOD System.Boolean IsNetworkEndpointValid(FinalFactory.Rendezvous.Contracts.NetworkEndpoint endpoint)
|
||||
METHOD System.Boolean IsOpaqueHttpCredentialValid(System.String value)
|
||||
METHOD System.Boolean IsPageSizeValid(System.Int32 pageSize)
|
||||
METHOD System.Boolean IsReportableConnectionOutcome(FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind outcome)
|
||||
METHOD System.Boolean IsUtf8LengthWithin(System.String value, System.Int32 maximumBytes)
|
||||
METHOD FinalFactory.Rendezvous.Contracts.RendezvousErrorCode ValidateContractVersion(System.Int32 contractVersion)
|
||||
TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest
|
||||
@@ -98,6 +117,7 @@ TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse
|
||||
CTOR ()
|
||||
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
||||
PROP System.String ClientPunchCapability {get;set;}
|
||||
PROP System.String ConnectionTicketDigest {get;set;}
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
|
||||
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
||||
@@ -137,8 +157,10 @@ TYPE FinalFactory.Rendezvous.Contracts.HealthResponse
|
||||
TYPE FinalFactory.Rendezvous.Contracts.HostJoinAttempt
|
||||
CTOR ()
|
||||
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
||||
PROP System.String ConnectionTicketDigest {get;set;}
|
||||
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
||||
PROP System.String HostPunchCapability {get;set;}
|
||||
PROP System.Boolean IsCancelled {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Contracts.JoinAttemptId
|
||||
CTOR (System.Guid value)
|
||||
@@ -173,6 +195,17 @@ TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
|
||||
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
|
||||
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
||||
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
||||
TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionToken
|
||||
CTOR ()
|
||||
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
||||
PROP System.String ConnectionTicket {get;set;}
|
||||
METHOD System.String ToString()
|
||||
TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionTokenCodec
|
||||
FIELD System.Int32 EncodedLength=43
|
||||
METHOD System.String ComputeDigest(System.String connectionTicket)
|
||||
METHOD System.String Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String derivedAuthenticator)
|
||||
METHOD System.Boolean MatchesDigest(System.String connectionTicket, System.String expectedDigest)
|
||||
METHOD System.Boolean TryDecode(System.String encoded, FinalFactory.Rendezvous.Contracts.NatIntroductionToken& token)
|
||||
TYPE FinalFactory.Rendezvous.Contracts.NatPunchPeerRole
|
||||
ENUM HostPresence=1
|
||||
ENUM Host=2
|
||||
@@ -220,6 +253,7 @@ TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionRequest
|
||||
PROP System.String BuildVersion {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
|
||||
PROP System.String DisplayName {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
|
||||
@@ -274,12 +308,14 @@ TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
|
||||
CTOR ()
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP System.String DiagnosticCode {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket ElapsedBucket {get;set;}
|
||||
PROP System.Int32 ElapsedMilliseconds {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Outcome {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse
|
||||
CTOR ()
|
||||
PROP System.Boolean Accepted {get;set;}
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP System.Boolean IsDuplicate {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Contracts.SessionCapacity
|
||||
CTOR ()
|
||||
PROP System.Int32 CurrentPlayers {get;set;}
|
||||
@@ -289,6 +325,7 @@ TYPE FinalFactory.Rendezvous.Contracts.SessionListing
|
||||
PROP System.String BuildVersion {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
|
||||
PROP System.String DisplayName {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
|
||||
@@ -330,6 +367,7 @@ TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest
|
||||
PROP System.String BuildVersion {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
|
||||
PROP System.String DisplayName {get;set;}
|
||||
PROP System.String LeaseToken {get;set;}
|
||||
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}
|
||||
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","connectionTicketDigest":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}
|
||||
|
||||
Reference in New Issue
Block a user