Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88ef946af5 | |||
| 2ff7cd6d9d | |||
| 7e3be2cad1 |
@@ -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
|
||||
|
||||
@@ -77,8 +77,9 @@ The initial service does not provide:
|
||||
|
||||
Rendezvous is under active roadmap development. The versioned contracts,
|
||||
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
|
||||
SDK coordination, and typed connection outcomes are implemented. The thin test
|
||||
client, deployment hardening, and production-readiness roadmap remain in progress;
|
||||
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.
|
||||
|
||||
@@ -88,6 +89,13 @@ 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).
|
||||
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
|
||||
|
||||
|
||||
@@ -21,6 +21,25 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +61,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
@@ -85,6 +123,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Payload Too Large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
@@ -127,6 +175,15 @@
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -243,6 +300,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
@@ -303,6 +379,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Payload Too Large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
@@ -353,6 +439,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
@@ -411,6 +516,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Payload Too Large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
@@ -441,6 +556,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
@@ -497,6 +631,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Payload Too Large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
@@ -517,6 +661,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
@@ -614,6 +777,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
@@ -706,6 +888,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
@@ -756,6 +957,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Payload Too Large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
@@ -788,6 +999,15 @@
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -857,6 +1077,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
@@ -930,6 +1169,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Payload Too Large",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
@@ -950,6 +1199,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
|
||||
@@ -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,98 @@
|
||||
# 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.
|
||||
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 and health probes 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. |
|
||||
|
||||
@@ -75,7 +75,8 @@ public sealed class RendezvousJoinClient : IRendezvousJoinClient
|
||||
RendezvousConnectionOutcomeSource.Caller,
|
||||
RendezvousConnectionFailureCategory.Lifecycle,
|
||||
RendezvousConnectionPhase.Authorization,
|
||||
elapsed.Elapsed));
|
||||
elapsed.Elapsed,
|
||||
dedicatedFallback));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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; } = [];
|
||||
|
||||
[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, 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,458 @@
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
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;
|
||||
|
||||
public AbuseProtectionService(
|
||||
IOptions<AbuseProtectionOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
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 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;
|
||||
lock (tracker.Gate)
|
||||
{
|
||||
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 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,80 @@
|
||||
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 acquired = healthEndpoint
|
||||
? protection.TryAcquireHealthIngress(
|
||||
context.Connection.RemoteIpAddress,
|
||||
out AbuseProtectionService.AbuseLease? lease,
|
||||
out int retryAfterSeconds)
|
||||
: 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -20,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)
|
||||
@@ -31,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");
|
||||
|
||||
@@ -80,6 +91,7 @@ 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)
|
||||
@@ -90,14 +102,17 @@ 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.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("ReportConnectionOutcome");
|
||||
|
||||
@@ -109,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)
|
||||
@@ -122,13 +138,29 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
|
||||
principal!,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
|
||||
: Error(result.Error);
|
||||
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,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult RenewLease(
|
||||
@@ -137,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)
|
||||
@@ -150,14 +183,30 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
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,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult UpdateSession(
|
||||
@@ -166,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)
|
||||
@@ -179,12 +229,28 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<bool> result = sessions.Update(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
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,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult DeleteSession(
|
||||
@@ -193,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)
|
||||
@@ -206,12 +273,28 @@ internal static class ContractEndpoints
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<bool> result = sessions.Delete(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
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,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult BrowseSessions(
|
||||
@@ -224,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)
|
||||
@@ -233,20 +318,35 @@ internal static class ContractEndpoints
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"BrowseSessions",
|
||||
Tenant(parsedGameId, parsedEnvironmentId),
|
||||
null,
|
||||
null,
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
ContractVersion = contractVersion,
|
||||
GameId = parsedGameId,
|
||||
EnvironmentId = parsedEnvironmentId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
ExcludeFull = excludeFull ?? false,
|
||||
Cursor = cursor,
|
||||
}, cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
|
||||
{
|
||||
ContractVersion = contractVersion,
|
||||
GameId = parsedGameId,
|
||||
EnvironmentId = parsedEnvironmentId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
ExcludeFull = excludeFull ?? false,
|
||||
Cursor = cursor,
|
||||
}, cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetSession(
|
||||
@@ -256,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)
|
||||
@@ -269,15 +371,30 @@ internal static class ContractEndpoints
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
BrowserServiceResult<GetSessionResponse> result = browser.Get(
|
||||
listingId,
|
||||
parsedGameId,
|
||||
parsedEnvironmentId,
|
||||
protocolVersion,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
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,
|
||||
parsedEnvironmentId,
|
||||
protocolVersion,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult BrowseHostJoinAttempts(
|
||||
@@ -287,23 +404,41 @@ internal static class ContractEndpoints
|
||||
[FromQuery] int? pageSize,
|
||||
[FromQuery] string? cursor,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
|
||||
listingId,
|
||||
contractVersion,
|
||||
leaseToken,
|
||||
pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
cursor,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
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,
|
||||
contractVersion,
|
||||
leaseToken,
|
||||
pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
cursor,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CreateJoinAttempt(
|
||||
[FromBody] CreateJoinAttemptRequest request,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -313,26 +448,58 @@ internal static class ContractEndpoints
|
||||
}
|
||||
|
||||
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
|
||||
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
|
||||
clientSubject,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
|
||||
: Error(result.Error);
|
||||
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,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CancelJoinAttempt(
|
||||
JoinAttemptId attemptId,
|
||||
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||
[FromServices] JoinAttemptService attempts,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JoinAttemptServiceResult<bool> result = attempts.Cancel(
|
||||
attemptId,
|
||||
clientPunchCapability,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
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,
|
||||
clientPunchCapability,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult ReportConnectionOutcome(
|
||||
@@ -340,16 +507,33 @@ internal static class ContractEndpoints
|
||||
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||
[FromBody] ReportConnectionOutcomeRequest request,
|
||||
[FromServices] ConnectionOutcomeService outcomes,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ConnectionOutcomeServiceResult result = outcomes.Report(
|
||||
attemptId,
|
||||
clientPunchCapability,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
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(
|
||||
@@ -377,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));
|
||||
@@ -392,6 +606,18 @@ 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,
|
||||
@@ -417,6 +643,7 @@ internal static class ContractEndpoints
|
||||
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.",
|
||||
|
||||
@@ -17,18 +17,26 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
||||
}
|
||||
|
||||
bool invalidRequest = exception is BadHttpRequestException or JsonException;
|
||||
httpContext.Response.StatusCode = invalidRequest
|
||||
? StatusCodes.Status400BadRequest
|
||||
: StatusCodes.Status500InternalServerError;
|
||||
bool payloadTooLarge = exception is BadHttpRequestException
|
||||
{
|
||||
StatusCode: StatusCodes.Status413PayloadTooLarge,
|
||||
};
|
||||
httpContext.Response.StatusCode = payloadTooLarge
|
||||
? StatusCodes.Status413PayloadTooLarge
|
||||
: invalidRequest
|
||||
? StatusCodes.Status400BadRequest
|
||||
: StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsJsonAsync(
|
||||
new ApiError
|
||||
{
|
||||
Code = invalidRequest
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.InternalError,
|
||||
Message = invalidRequest
|
||||
? "The request body, route, or query value is invalid."
|
||||
: "The service could not complete the request.",
|
||||
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.",
|
||||
},
|
||||
ContractJson.Options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -8,6 +9,7 @@ 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);
|
||||
@@ -96,6 +98,31 @@ builder.Services.AddOpenApi("v1", static options =>
|
||||
[attemptReference] = [],
|
||||
});
|
||||
}
|
||||
|
||||
foreach (OpenApiOperation operation in path.Operations.Values)
|
||||
{
|
||||
if (operation.Responses is null
|
||||
|| !operation.Responses.TryGetValue(
|
||||
StatusCodes.Status429TooManyRequests.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
out IOpenApiResponse? response)
|
||||
|| response is not OpenApiResponse concreteResponse)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
concreteResponse.Headers ??=
|
||||
new Dictionary<string, IOpenApiHeader>(StringComparer.OrdinalIgnoreCase);
|
||||
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;
|
||||
@@ -107,6 +134,45 @@ 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.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton<AbuseProtectionService>();
|
||||
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();
|
||||
@@ -176,13 +242,19 @@ if (!isOpenApiGeneration)
|
||||
WebApplication app = builder.Build();
|
||||
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
||||
|
||||
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
|
||||
{
|
||||
app.UseForwardedHeaders();
|
||||
}
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||
app.MapOpenApi();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
app.MapGet(
|
||||
"/health/live",
|
||||
static () => Results.Ok(new HealthResponse { Status = "live" }))
|
||||
.Produces<HealthResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.WithName("GetLiveness")
|
||||
.WithTags("Health");
|
||||
app.MapGet(
|
||||
@@ -198,6 +270,7 @@ app.MapGet(
|
||||
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
|
||||
: Results.Ok(new HealthResponse { Status = "ready" }))
|
||||
.Produces<HealthResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("GetReadiness")
|
||||
.WithTags("Health");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
@@ -36,7 +37,8 @@ internal enum NatMediationResult
|
||||
internal sealed class NatMediationProcessor(
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities,
|
||||
JoinAttemptService joinAttempts)
|
||||
JoinAttemptService joinAttempts,
|
||||
AbuseProtectionService? abuseProtection = null)
|
||||
{
|
||||
public NatMediationResult ProcessDatagram(
|
||||
ReadOnlySpan<byte> encoded,
|
||||
@@ -44,6 +46,29 @@ 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)
|
||||
{
|
||||
|
||||
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||
|| datagram is null
|
||||
|| datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters
|
||||
@@ -63,7 +88,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 +101,7 @@ internal sealed class NatMediationProcessor(
|
||||
return result;
|
||||
}
|
||||
|
||||
return ProcessRequest(
|
||||
return ProcessRequestCore(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
@@ -98,6 +123,39 @@ 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) => ProcessRequestCore(
|
||||
claimedLocalEndpoint,
|
||||
observedPublicEndpoint,
|
||||
token,
|
||||
introductionSink,
|
||||
cancellationToken);
|
||||
|
||||
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 +164,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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,6 +5,38 @@
|
||||
"Port": 9050,
|
||||
"MaxDatagramsPerPoll": 256,
|
||||
"PollIntervalMilliseconds": 2
|
||||
},
|
||||
"AbuseProtection": {
|
||||
"WindowSeconds": 1,
|
||||
"MaxTrackedKeys": 100000,
|
||||
"CriticalTrackedKeyReserve": 2048,
|
||||
"UdpTrackedKeyLimit": 70000,
|
||||
"TrustedProxyAddresses": [],
|
||||
"HealthGlobalRequestsPerWindow": 1000,
|
||||
"HealthGlobalConcurrency": 32,
|
||||
"HealthIpPrefixRequestsPerWindow": 120,
|
||||
"HealthIpPrefixConcurrency": 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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -161,10 +161,17 @@ public sealed class RendezvousJoinClientTests
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,5 +149,45 @@ 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");
|
||||
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(12, 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"),
|
||||
];
|
||||
foreach ((string operationPath, string method) in bodyOperations)
|
||||
{
|
||||
Assert.True(root.GetProperty("paths")
|
||||
.GetProperty(operationPath)
|
||||
.GetProperty(method)
|
||||
.GetProperty("responses")
|
||||
.TryGetProperty("413", out _));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -304,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);
|
||||
@@ -324,6 +327,7 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
await app.StartAsync();
|
||||
IServer server = app.Services.GetRequiredService<IServer>();
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
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.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");
|
||||
|
||||
AssertAccepted(protection, source, "BrowseSessions");
|
||||
AssertAccepted(protection, source, "BrowseSessions");
|
||||
AssertRejected(protection, source, "BrowseSessions");
|
||||
AssertAccepted(protection, source, "RenewSessionLease");
|
||||
AssertRejected(protection, source, "RenewSessionLease");
|
||||
}
|
||||
|
||||
[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();
|
||||
|
||||
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,
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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,53 +118,141 @@ 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));
|
||||
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||
host.NatPunchModule.SendNatIntroduceRequest(
|
||||
mediator,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Host,
|
||||
created.MediationHandle,
|
||||
hostAttempt.HostPunchCapability));
|
||||
client.NatPunchModule.SendNatIntroduceRequest(
|
||||
mediator,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Client,
|
||||
created.MediationHandle,
|
||||
created.ClientPunchCapability));
|
||||
|
||||
while ((hostTickets.Count == 0 || clientTickets.Count == 0)
|
||||
&& !timeout.IsCancellationRequested)
|
||||
if (restartMediator)
|
||||
{
|
||||
host.PollEvents();
|
||||
host.NatPunchModule.PollEvents();
|
||||
client.PollEvents();
|
||||
client.NatPunchModule.PollEvents();
|
||||
await Task.Delay(5, timeout.Token);
|
||||
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);
|
||||
}
|
||||
|
||||
string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal));
|
||||
string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal));
|
||||
Assert.Equal(hostTicket, clientTicket);
|
||||
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||
hostTicket,
|
||||
out NatIntroductionToken? introduction));
|
||||
Assert.NotNull(introduction);
|
||||
Assert.Equal(created.AttemptId, introduction.AttemptId);
|
||||
Assert.Equal(43, introduction.ConnectionTicket.Length);
|
||||
await AssertNativeIntroductionAsync(
|
||||
service,
|
||||
host,
|
||||
client,
|
||||
hostTickets,
|
||||
clientTickets,
|
||||
created,
|
||||
hostAttempt,
|
||||
expectedCount: restartMediator ? 2 : 1,
|
||||
cancellationToken: timeout.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
host.Stop();
|
||||
client.Stop();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
if (service is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (serviceStarted)
|
||||
{
|
||||
using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5));
|
||||
await service.StopAsync(cleanup.Token);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AssertNativeIntroductionAsync(
|
||||
UdpMediatorService service,
|
||||
NetManager host,
|
||||
NetManager client,
|
||||
List<string> hostTickets,
|
||||
List<string> clientTickets,
|
||||
CreateJoinAttemptResponse created,
|
||||
HostJoinAttempt hostAttempt,
|
||||
int expectedCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
|
||||
host.NatPunchModule.SendNatIntroduceRequest(
|
||||
mediator,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Host,
|
||||
created.MediationHandle,
|
||||
hostAttempt.HostPunchCapability));
|
||||
client.NatPunchModule.SendNatIntroduceRequest(
|
||||
mediator,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Client,
|
||||
created.MediationHandle,
|
||||
created.ClientPunchCapability));
|
||||
|
||||
while ((hostTickets.Distinct(StringComparer.Ordinal).Count() < expectedCount
|
||||
|| clientTickets.Distinct(StringComparer.Ordinal).Count() < expectedCount)
|
||||
&& !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
host.PollEvents();
|
||||
host.NatPunchModule.PollEvents();
|
||||
client.PollEvents();
|
||||
client.NatPunchModule.PollEvents();
|
||||
await Task.Delay(5, cancellationToken);
|
||||
}
|
||||
|
||||
List<string> distinctHostTickets = hostTickets.Distinct(StringComparer.Ordinal).ToList();
|
||||
List<string> distinctClientTickets = clientTickets.Distinct(StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(expectedCount, distinctHostTickets.Count);
|
||||
Assert.Equal(expectedCount, distinctClientTickets.Count);
|
||||
string hostTicket = distinctHostTickets[^1];
|
||||
string clientTicket = distinctClientTickets[^1];
|
||||
Assert.Equal(hostTicket, clientTicket);
|
||||
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||
hostTicket,
|
||||
out NatIntroductionToken? introduction));
|
||||
Assert.NotNull(introduction);
|
||||
Assert.Equal(created.AttemptId, introduction.AttemptId);
|
||||
Assert.Equal(43, introduction.ConnectionTicket.Length);
|
||||
}
|
||||
|
||||
private static UdpMediatorService CreateMediator(NatMediationProcessor processor, int port) => new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = port,
|
||||
MaxDatagramsPerPoll = 8,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
|
||||
[Fact]
|
||||
public async Task FrozenV1EnvelopeIsConsumedOnTheLiteNetSocketWithinAmplificationBudget()
|
||||
{
|
||||
@@ -249,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
|
||||
{
|
||||
@@ -280,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>();
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user