Compare commits

...

3 Commits

Author SHA1 Message Date
KyuubiYoru 88ef946af5 feat(server): harden hostile input and overload behavior (#15)
quality-gate / quality (push) Failing after 1m5s
2026-07-16 12:37:38 +02:00
KyuubiYoru 2ff7cd6d9d test(integration): add deterministic NAT topology harness (#14)
quality-gate / quality (push) Failing after 1m3s
2026-07-16 11:50:53 +02:00
KyuubiYoru 7e3be2cad1 feat(tooling): add standalone rendezvous test client (#25)
quality-gate / quality (push) Failing after 1m3s
2026-07-16 11:05:56 +02:00
39 changed files with 6138 additions and 150 deletions
+53
View File
@@ -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
+10 -2
View File
@@ -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
+268
View File
@@ -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": {
+97
View File
@@ -0,0 +1,97 @@
# Diagnostic TestClient integration guide
Tracking: #25
`FinalFactory.Rendezvous.TestClient` is the smallest supported public-SDK consumer.
It exists for integration development, CI smoke checks, deployment verification,
and operator diagnosis. It is intentionally not a production game client, game
server, matchmaking UI, or relay.
The automated scenario matrix, privileged Linux namespace run, and topology
limitations are documented in the [deterministic topology harness](topology-harness.md).
## Prerequisites
Start a configured Rendezvous service and note both its HTTP base URL and UDP
mediator endpoint. The host needs a tenant-scoped publisher credential from the
deployment secret boundary. Put it in an environment variable and pass only that
variable's name when the default is unsuitable:
```bash
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<deployment-supplied value>'
```
Never put the credential in a command argument, URL, checked-in configuration,
shell trace, or captured test fixture. The development server's signing material
is process-ephemeral; credentials from a prior development process are invalid.
## Manual three-terminal flow
Start the host:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
--game space-game --environment development --region local --protocol 1
```
Browse from another terminal:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
browse --service http://127.0.0.1:5000/ \
--game space-game --environment development --region local --protocol 1
```
Join from a third terminal. Omit `--listing` for an interactive choice:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
join --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
--game space-game --environment development --region local --protocol 1 \
--listing 00000000-0000-0000-0000-000000000000
```
Replace the sample UUID with the public listing ID printed by host or browse.
Host and join each create one caller-owned LiteNetLib manager. That same socket
sends presence/punch traffic, establishes the authenticated direct connection,
and carries the ping/echo/ack/completion payload. The final completion confirms
that the host received the reliable acknowledgement; none of this traffic passes through the HTTP
service or UDP mediator.
## CI and deployment smoke flow
Use `--script --json`, set `--listing` when deterministic selection matters, and
check the documented process exit code. `--timeout-seconds` bounds each startup,
traversal, or direct-traffic stage; a script host also uses it as its total runtime
unless `--run-seconds` is explicit. A host can add `--exit-after-echo` so it
terminates after the joining peer acknowledges direct traffic and receives the
host's completion confirmation. Every wait is
bounded by coordinator state and `--timeout-seconds`; no orchestration should use
an unbounded sleep.
The normal test suite contains a real process gate that starts the built Server,
host TestClient, and join TestClient, waits for readiness and versioned events,
and verifies direct traffic, cleanup, JSON shape, and secret canaries. Process
trees are force-terminated in the test cleanup path if normal shutdown fails.
Useful success events are:
- `host.registered`, `host.ready`, `host.direct-traffic`, and `host.deregistered`;
- `browse.completed` and `browse.session`; and
- `join.connected`, `join.direct-traffic`, and `join.outcome-report`.
Failure events preserve stable typed phases and outcomes. When a terminal outcome
contains a configured dedicated endpoint, `join.fallback` reports `available`
with endpoint type `dedicated`; no raw address is printed and no fallback is
started implicitly.
## What the proof does and does not establish
The deterministic loopback test proves the complete service/host/client protocol,
ticket admission, and peer-to-peer payload path. Loopback is not evidence that all
consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths,
or platform policies permit hole punching. Same-LAN, separated observed endpoints,
network namespaces/containers, mediator restart, and adverse topology coverage
belong to the topology harness tracked by #14. Production rollout still requires
tests from representative networks and a game-owned fallback policy.
+91
View File
@@ -0,0 +1,91 @@
# Deterministic topology harness
Issue #14 is verified at three layers. The layers are deliberately separate so
the always-on gate remains deterministic while privileged CI workers can add a
stronger operating-system topology without overstating what local emulation
proves about the public Internet.
## Always-on public-process gate
`TestClientProcessIntegrationTests` launches the built server and the same
`FinalFactory.Rendezvous.TestClient` executable shipped to operators. Every
child process uses `--script --json`, dynamic HTTP and UDP ports, bounded
state-driven waits, and enforced process-tree cleanup.
The suite proves:
| Scenario | Required observation |
| --- | --- |
| Three-party happy path | register, presence-ready, browse, authorize, punch, authenticated LiteNetLib connection, direct ping/echo/ack/completion traffic, outcome report, disconnect, deregister |
| Same-LAN candidate | the connected peer is reported as `loopback` or `private`, never inferred merely from an introduction callback |
| Empty and missing selection | browse exits `11`; exact missing lookup exits `10` |
| Wrong tenant/protocol | no listing is returned for an incompatible protocol; exact joins with either mismatch fail before `join.punch` |
| Traversal timeout | an unreachable mediator produces typed `PunchTimedOut`, exits `12`, advertises the configured dedicated fallback, and never connects to it |
| Caller cancellation | POSIX `SIGINT` exits `130`, deregisters the listing, and removes it from public lookup |
| Abrupt host loss | the listing disappears after the presence window and before its lease expires; public exact lookup intentionally reports `NotFound` |
| Bounded host without a peer | exits `13` and still deregisters |
Captured output is parsed as the stable JSON v1 event schema. Publisher
credentials and signing-key material are checked against all captured output.
The direct traffic payload is handled only by the caller-owned host and client
LiteNetLib managers; the HTTP service and mediator do not implement or observe
the echo protocol.
Run the always-on scenarios with:
```bash
dotnet test Rendezvous.slnx --configuration Release --no-build \
--filter FullyQualifiedName~TestClientProcessIntegrationTests
```
## Deterministic protocol and adverse-state gate
The following real service-boundary tests cover conditions that a public CLI
cannot safely manufacture by accepting raw capabilities or tickets:
| Scenario | Test evidence |
| --- | --- |
| Same-NAT private candidates | `NatMediationProcessorTests.MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates` |
| Separate observed endpoints | `NatMediationProcessorTests.DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints` |
| One-time introduction and replay | `InMemoryEphemeralRendezvousStoreTests.AttemptCapabilitiesAndIntroductionAreOneTime` |
| Direct ticket replay | `RendezvousCoordinatorIntegrationTests.CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay` |
| Wrong tenant/protocol and stale presence | `InMemoryEphemeralRendezvousStoreTests.JoinRequiresExactScopeProtocolAndFreshHostPresence` |
| Cancellation and late callbacks | `RendezvousCoordinatorBehaviorTests.CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt` |
| Mediator restart | both cases of `UdpMediatorServiceTests.NativeLiteNetLibRequestsIntroduceTheAuthorizedPair`; the restarted case rebinds the same UDP port and completes a native LiteNetLib introduction |
These tests use fake monotonic clocks or state predicates where expiry and race
ordering matter. They do not use fixed sleeps as proof of state.
## Privileged Linux namespace gate
When a Linux CI worker can create network namespaces, the workflow sets
`RENDEZVOUS_RUN_NETNS_TESTS=1` and reruns
`PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints`.
The test creates a temporary WAN bridge, an isolated service namespace, two NAT
router namespaces, and isolated host/client LAN namespaces. Each NAT has its own
inside subnet and WAN address. Linux forwarding plus per-router MASQUERADE rules
force the service to observe separate translated endpoints; the public TestClient
processes must then complete authenticated direct traffic through those mappings
using the public candidate. Namespaces, rules, veth pairs, bridge, processes, and
sockets are removed in bounded async-disposal paths. A cleanup failure fails the
test.
If `ip netns add`/`iptables` is unavailable or the worker lacks `CAP_NET_ADMIN`,
CI records the limitation and keeps the always-on loopback suite as the required gate.
To request the privileged run explicitly:
```bash
RENDEZVOUS_RUN_NETNS_TESTS=1 dotnet test Rendezvous.slnx \
--configuration Release --no-build \
--filter FullyQualifiedName~PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints
```
## What this does not prove
Loopback, MASQUERADE, and namespace routing cannot reproduce every consumer router,
carrier-grade NAT, firewall, IPv6 transition mechanism, symmetric NAT mapping,
or real-world packet-loss pattern. The separate-observed-endpoint processor
test proves that untrusted private claims are excluded and public candidates are
selected; it is not presented as universal Internet traversal proof. Real
network canaries and measured production readiness remain the scope of issue
#23.
+98
View File
@@ -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.
+1 -1
View File
@@ -11,7 +11,7 @@ backlog where the control is implemented and verified.
| Per-game credentials and signing keys | Provisioned principals and versioned keys are scoped to game/environment; secrets come from a provider and never a public binary. (#5) | Cross-tenant authorization tests, rotation/overlap/revocation tests, and secret scans. |
| 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);
}
}
}