Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07004cd75f | |||
| cf14836d48 | |||
| 609dad7cf1 | |||
| 08729ae25c | |||
| be732de7c9 | |||
| 88ef946af5 | |||
| 2ff7cd6d9d | |||
| 7e3be2cad1 | |||
| 94aba8a3bb | |||
| b4b6072fe1 | |||
| 6d076c281a | |||
| 1baa1055dc | |||
| 06c3973ce7 | |||
| a9a2b3db35 | |||
| 49564c7e7e | |||
| 02ca502a76 |
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.idea
|
||||||
|
.vs
|
||||||
|
.codex
|
||||||
|
.agents
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
TestResults
|
||||||
|
deploy/compose/secrets
|
||||||
|
deploy/compose/.smoke.env
|
||||||
|
docs
|
||||||
|
tests
|
||||||
@@ -35,3 +35,116 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test Rendezvous.slnx --configuration Release --no-build
|
run: dotnet test Rendezvous.slnx --configuration Release --no-build
|
||||||
|
|
||||||
|
- name: Run quick capacity and resilience gate
|
||||||
|
run: ./scripts/run-capacity-gate.sh
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
container:
|
||||||
|
needs: quality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install .NET SDK
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.301
|
||||||
|
|
||||||
|
- name: Build deployment diagnostic
|
||||||
|
run: |
|
||||||
|
dotnet restore src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj --locked-mode
|
||||||
|
dotnet build src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj --configuration Release --no-restore
|
||||||
|
|
||||||
|
- name: Build and exercise hardened container
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
compose_file="deploy/compose/compose.yaml"
|
||||||
|
secret="deploy/compose/secrets/signing-key"
|
||||||
|
cleanup() {
|
||||||
|
RENDEZVOUS_UID=1654 RENDEZVOUS_GID=1654 \
|
||||||
|
docker compose -f "$compose_file" down --volumes >/dev/null 2>&1 || true
|
||||||
|
rm -f "$secret"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
install -d -m 0700 deploy/compose/secrets
|
||||||
|
openssl rand -out "$secret" 32
|
||||||
|
chmod 0444 "$secret"
|
||||||
|
export RENDEZVOUS_UID=1654
|
||||||
|
export RENDEZVOUS_GID=1654
|
||||||
|
docker compose -f "$compose_file" up --build --detach
|
||||||
|
container_id="$(docker compose -f "$compose_file" ps -q rendezvous)"
|
||||||
|
test -n "$container_id"
|
||||||
|
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
|
||||||
|
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container_id")" = "true"
|
||||||
|
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/app/appsettings.Production.json\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
||||||
|
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/run/secrets/rendezvous-signing-key\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
||||||
|
for attempt in {1..100}; do
|
||||||
|
if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if (( attempt == 100 )); then
|
||||||
|
docker compose -f "$compose_file" logs rendezvous
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
./scripts/smoke-deployment.sh
|
||||||
|
docker compose -f "$compose_file" stop --timeout 40 rendezvous
|
||||||
|
test "$(docker inspect --format '{{.State.Running}}' "$container_id")" = "false"
|
||||||
|
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = "0"
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ TestResults/
|
|||||||
*.suo
|
*.suo
|
||||||
*.user
|
*.user
|
||||||
*.userosscache
|
*.userosscache
|
||||||
|
deploy/compose/.smoke.env
|
||||||
|
artifacts/
|
||||||
|
deploy/compose/secrets/*
|
||||||
|
!deploy/compose/secrets/.gitignore
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build
|
||||||
|
|
||||||
|
WORKDIR /source
|
||||||
|
COPY Directory.Build.props Directory.Packages.props NuGet.config global.json Rendezvous.slnx ./
|
||||||
|
COPY src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj src/FinalFactory.Rendezvous.Contracts/packages.lock.json src/FinalFactory.Rendezvous.Contracts/
|
||||||
|
COPY src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj src/FinalFactory.Rendezvous.Server/packages.lock.json src/FinalFactory.Rendezvous.Server/
|
||||||
|
RUN dotnet restore src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj --locked-mode
|
||||||
|
|
||||||
|
COPY src/FinalFactory.Rendezvous.Contracts/ src/FinalFactory.Rendezvous.Contracts/
|
||||||
|
COPY src/FinalFactory.Rendezvous.Server/ src/FinalFactory.Rendezvous.Server/
|
||||||
|
RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--no-restore \
|
||||||
|
--output /out \
|
||||||
|
/p:UseAppHost=false \
|
||||||
|
/p:OpenApiGenerateDocuments=false
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0.9-noble-chiseled@sha256:f820c4fbfb8bb204c3bbe05c69d48cd039cd0e67aa8f13ac1cec168819b90643 AS runtime
|
||||||
|
|
||||||
|
ENV ASPNETCORE_HTTP_PORTS=8080 \
|
||||||
|
DOTNET_EnableDiagnostics=0 \
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||||
|
TMPDIR=/tmp
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build --chown=1654:1654 /out/ ./
|
||||||
|
USER 1654:1654
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
EXPOSE 9050/udp
|
||||||
|
ENTRYPOINT ["dotnet", "FinalFactory.Rendezvous.Server.dll"]
|
||||||
@@ -17,7 +17,7 @@ Rendezvous is intended to provide:
|
|||||||
- Isolation by game, environment, protocol version, and region.
|
- Isolation by game, environment, protocol version, and region.
|
||||||
- Operational health, metrics, logging, administration, and rate limiting.
|
- Operational health, metrics, logging, administration, and rate limiting.
|
||||||
|
|
||||||
UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server or a future relay service.
|
UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server. The v1 SDK returns an optional game-configured endpoint for an explicit caller decision; it never routes automatically, and v1 does not provide a relay.
|
||||||
|
|
||||||
## Connection flow
|
## Connection flow
|
||||||
|
|
||||||
@@ -38,7 +38,11 @@ UDP hole punching cannot guarantee a direct connection through every network. Sy
|
|||||||
- `FinalFactory.Rendezvous.TestClient` — thin interactive and scriptable host/browser/join diagnostic built only on the public SDK.
|
- `FinalFactory.Rendezvous.TestClient` — thin interactive and scriptable host/browser/join diagnostic built only on the public SDK.
|
||||||
- `FinalFactory.Rendezvous.Tests` — unit, integration, security, and connection-lifecycle tests.
|
- `FinalFactory.Rendezvous.Tests` — unit, integration, security, and connection-lifecycle tests.
|
||||||
|
|
||||||
The server directory and NAT mediator begin as separate modules in one deployable service because they share session, lease, authorization, and endpoint state. Their internal boundary should allow independent deployment later if scale, availability, or security requirements diverge.
|
The server directory and NAT mediator are separate modules in one single-active
|
||||||
|
deployable service because they share ephemeral session, lease, authorization,
|
||||||
|
replay, and endpoint state. Their internal boundary can support a future
|
||||||
|
explicitly designed shared-state architecture; operators must not create
|
||||||
|
multiple active v1 replicas.
|
||||||
|
|
||||||
## Service boundaries
|
## Service boundaries
|
||||||
|
|
||||||
@@ -75,7 +79,15 @@ The initial service does not provide:
|
|||||||
|
|
||||||
## Project status
|
## Project status
|
||||||
|
|
||||||
Rendezvous is currently in its initial design and bootstrap stage. The first implementation should establish the contracts, directory leases, LiteNetLib mediator, client SDK, thin test client, and a three-party integration test before either game depends on it for production connectivity.
|
Rendezvous is under active roadmap development. The versioned contracts,
|
||||||
|
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
|
||||||
|
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
|
||||||
|
deterministic NAT topology harness, hostile-input controls,
|
||||||
|
observability/operator surface, secure single-active Linux deployment, and
|
||||||
|
numeric capacity/resilience gates are implemented. Packaging, consumer pilots,
|
||||||
|
and final production-readiness gates remain in progress;
|
||||||
|
participating games must not treat the current repository as a finished production
|
||||||
|
service until those gates land.
|
||||||
|
|
||||||
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
|
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
|
||||||
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
|
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
|
||||||
@@ -83,6 +95,22 @@ The frozen v1 wire surface is documented in the
|
|||||||
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
|
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
|
||||||
Tenant policy, publisher/operator principals, and production key custody are
|
Tenant policy, publisher/operator principals, and production key custody are
|
||||||
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
|
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
|
||||||
|
Layered HTTP/UDP budgets, overload behavior, and safe operational tuning are
|
||||||
|
defined in [hostile-input and overload protection](docs/security/abuse-protection.md).
|
||||||
|
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
|
||||||
|
operator controls are defined in the
|
||||||
|
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
|
||||||
|
The pinned non-root container, production topology, graceful drain, Linux
|
||||||
|
hardening, smoke procedure, and recovery lifecycle are documented in
|
||||||
|
[secure single-active Linux deployment](docs/deployment/linux.md).
|
||||||
|
The numeric core-state candidate profile, public launch objectives, accelerated
|
||||||
|
soak, resilience matrix, and single-active scaling decision are recorded in
|
||||||
|
[capacity and resilience gates](docs/operations/capacity-and-resilience.md).
|
||||||
|
The scriptable host/browser/join diagnostic and its stable automation contract are
|
||||||
|
documented in the [TestClient integration guide](docs/integration/test-client.md).
|
||||||
|
The always-on three-party scenarios, optional Linux namespace topology, and
|
||||||
|
simulation limits are documented in the
|
||||||
|
[deterministic topology harness](docs/integration/topology-harness.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -99,9 +127,11 @@ dotnet test Rendezvous.slnx --configuration Release --no-build
|
|||||||
Run the bootstrap server with
|
Run the bootstrap server with
|
||||||
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
|
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
|
||||||
the configured UDP mediator port; both stop through normal host cancellation.
|
the configured UDP mediator port; both stop through normal host cancellation.
|
||||||
The launch profile uses an ephemeral development-only signing key. Production
|
The launch profile uses separate ephemeral development-only publisher and operator
|
||||||
startup fails closed until externally supplied game policies and `env:` signing
|
signing keys. Production
|
||||||
key references resolve to valid key material; no reusable game secret is stored
|
startup fails closed until its advertised endpoints, proxy trust boundary,
|
||||||
|
externally supplied game policies, and `env:` (base64) or `file:` (raw,
|
||||||
|
absolute, non-symlink) signing-key references resolve safely; no reusable game secret is stored
|
||||||
in this repository or the public Client package.
|
in this repository or the public Client package.
|
||||||
The project dependency rules and supported runtime choices are documented in
|
The project dependency rules and supported runtime choices are documented in
|
||||||
[project and dependency boundaries](docs/architecture/project-boundaries.md).
|
[project and dependency boundaries](docs/architecture/project-boundaries.md).
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<Project Path="src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
|
<Project Path="src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/FinalFactory.Rendezvous.Capacity/FinalFactory.Rendezvous.Capacity.csproj" />
|
||||||
<Project Path="tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj" />
|
<Project Path="tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"AllowedHosts": "localhost;127.0.0.1",
|
||||||
|
"Rendezvous": {
|
||||||
|
"Deployment": {
|
||||||
|
"PublicHttpBaseUrl": "https://localhost/",
|
||||||
|
"PublicUdpHost": "127.0.0.1",
|
||||||
|
"PublicUdpPort": 9050,
|
||||||
|
"DrainDeadlineSeconds": 30,
|
||||||
|
"MinimumDrainSeconds": 1,
|
||||||
|
"SingleActiveInstance": true,
|
||||||
|
"AllowPrivatePublicEndpoints": true
|
||||||
|
},
|
||||||
|
"Udp": {
|
||||||
|
"ListenAddress": "0.0.0.0",
|
||||||
|
"Port": 9050
|
||||||
|
},
|
||||||
|
"AbuseProtection": {
|
||||||
|
"TrustedProxyAddresses": ["127.0.0.1"],
|
||||||
|
"OperatorAllowedAddresses": ["127.0.0.1"]
|
||||||
|
},
|
||||||
|
"Provisioning": {
|
||||||
|
"Issuer": "final-factory-rendezvous-smoke",
|
||||||
|
"Audience": "rendezvous-service",
|
||||||
|
"ClockSkewSeconds": 30,
|
||||||
|
"SigningKeys": [
|
||||||
|
{
|
||||||
|
"KeyId": "local-smoke-1",
|
||||||
|
"SecretReference": "file:/run/secrets/rendezvous-signing-key",
|
||||||
|
"CredentialKinds": ["DedicatedPublisher"],
|
||||||
|
"GameId": "space-game",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"NotBefore": "2026-01-01T00:00:00Z",
|
||||||
|
"SignUntil": "2100-01-01T00:00:00Z",
|
||||||
|
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Games": [
|
||||||
|
{
|
||||||
|
"GameId": "space-game",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"Enabled": true,
|
||||||
|
"ProtocolVersions": [1],
|
||||||
|
"Regions": ["local"],
|
||||||
|
"VisibilityModes": ["Public"],
|
||||||
|
"PublisherTrustModes": ["ManagedDedicated"],
|
||||||
|
"MetadataValueMaxBytes": {},
|
||||||
|
"RequiredMetadataKeys": [],
|
||||||
|
"MetadataMaxBytes": 512,
|
||||||
|
"MetadataMaxKeys": 0,
|
||||||
|
"MaxListingsPerPrincipal": 10,
|
||||||
|
"MaxAnonymousListingsPerAddress": 0,
|
||||||
|
"MaxActiveJoinAttempts": 100,
|
||||||
|
"FallbackPolicy": "Disabled"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: rendezvous-local
|
||||||
|
|
||||||
|
services:
|
||||||
|
rendezvous:
|
||||||
|
image: finalfactory/rendezvous:local
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
init: true
|
||||||
|
user: "${RENDEZVOUS_UID:?set RENDEZVOUS_UID to a non-root host UID}:${RENDEZVOUS_GID:?set RENDEZVOUS_GID to its GID}"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=16m,uid=${RENDEZVOUS_UID},gid=${RENDEZVOUS_GID},mode=0700
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
pids_limit: 128
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 4096
|
||||||
|
hard: 4096
|
||||||
|
stop_grace_period: 40s
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
|
ASPNETCORE_HTTP_PORTS: "8080"
|
||||||
|
volumes:
|
||||||
|
- ./appsettings.Production.json:/app/appsettings.Production.json:ro
|
||||||
|
- ./secrets/signing-key:/run/secrets/rendezvous-signing-key:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080/tcp"
|
||||||
|
- "9050:9050/udp"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Final Factory Rendezvous service
|
||||||
|
Documentation=https://git.finalfactory.de/HeiKyu/Rendezvous
|
||||||
|
After=network-online.target time-sync.target
|
||||||
|
Wants=network-online.target time-sync.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rendezvous
|
||||||
|
Group=rendezvous
|
||||||
|
WorkingDirectory=/opt/rendezvous
|
||||||
|
ExecStart=/usr/bin/dotnet /opt/rendezvous/FinalFactory.Rendezvous.Server.dll
|
||||||
|
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
Environment=ASPNETCORE_HTTP_PORTS=8080
|
||||||
|
Environment=DOTNET_EnableDiagnostics=0
|
||||||
|
EnvironmentFile=-/etc/rendezvous/rendezvous.env
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=40s
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateDevices=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
LockPersonality=true
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
UMask=0077
|
||||||
|
LimitNOFILE=4096
|
||||||
|
CPUQuota=200%
|
||||||
|
MemoryMax=2G
|
||||||
|
TasksMax=128
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+2473
-32
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,7 @@ them but must not raise them without security review.
|
|||||||
| Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort |
|
| Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort |
|
||||||
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
|
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
|
||||||
| Opaque HTTP credential | 1,024 bytes encoded |
|
| Opaque HTTP credential | 1,024 bytes encoded |
|
||||||
| UDP capability or ticket | 768 bytes encoded, with the complete datagram still at most 1,200 bytes |
|
| UDP capability or connection ticket | 192 base64url characters; NAT punch capabilities also remain below LiteNetLib's 256-character token ceiling; complete datagram at most 1,200 bytes |
|
||||||
| Clock skew | 30 seconds maximum when validating issued/not-before/expiry times |
|
| Clock skew | 30 seconds maximum when validating issued/not-before/expiry times |
|
||||||
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
|
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
|
||||||
| Host presence freshness | 20 seconds |
|
| Host presence freshness | 20 seconds |
|
||||||
@@ -129,9 +129,18 @@ until the owner records:
|
|||||||
- which games may enable anonymous unlisted player hosting;
|
- which games may enable anonymous unlisted player hosting;
|
||||||
- deployment regions, data-processing jurisdiction, and approval of the stated
|
- deployment regions, data-processing jurisdiction, and approval of the stated
|
||||||
30-day audit/13-month aggregate retention periods;
|
30-day audit/13-month aggregate retention periods;
|
||||||
- the per-game dedicated fallback endpoint policy;
|
- the per-game dedicated fallback endpoint policy.
|
||||||
- the measured supported profile and whether the 99.5% single-active objective
|
|
||||||
is sufficient or shared-state/high-availability work must be brought forward.
|
Issue #18 measured and ratified the original 2-vCPU/2-GiB, 25,000-listing,
|
||||||
|
10,000-attempt core-state candidate profile and retained the 99.5% single-active
|
||||||
|
topology. It does not claim that core measurements prove public HTTP/UDP SLOs.
|
||||||
|
The versioned evidence, RTO, failure domains, and explicit signals that trigger
|
||||||
|
shared-state/high-availability work are recorded in the
|
||||||
|
[capacity and resilience gate](../operations/capacity-and-resilience.md). The
|
||||||
|
real-network canary in #23 must confirm that the proposed regional launch load
|
||||||
|
fits this profile and validate the public SLOs; it may lower the launch cap but
|
||||||
|
may not silently enable a
|
||||||
|
second active instance.
|
||||||
|
|
||||||
These are configuration and launch decisions, not permission to weaken the
|
These are configuration and launch decisions, not permission to weaken the
|
||||||
tenant, replay, endpoint-verification, or secret-handling controls.
|
tenant, replay, endpoint-verification, or secret-handling controls.
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# ADR 0004: atomic ephemeral state and single-active availability
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #6
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Listings, leases, endpoint observations, join attempts, and replay decisions must
|
||||||
|
move together. A partially committed authorization can expose an expired listing,
|
||||||
|
reuse a capability, or introduce an endpoint that was never authorized. V1 is a
|
||||||
|
single-active service, so it needs honest bounded in-memory behavior rather than
|
||||||
|
a database-shaped abstraction that implies unavailable durability or scale.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`IEphemeralRendezvousStore` is the atomic boundary for directory, lease, presence,
|
||||||
|
attempt, endpoint, replay, revocation, and drain transitions. The v1 implementation
|
||||||
|
serializes each transition under one process-local lock. This deliberately favors
|
||||||
|
simple, auditable correctness at the initial 25,000-listing/10,000-attempt ceiling.
|
||||||
|
It retains only immutable listing data, opaque credential fingerprints, observed
|
||||||
|
endpoints, monotonic deadlines, and bounded idempotency/replay records.
|
||||||
|
|
||||||
|
Every collection has an independent configured ceiling. An operation checks all
|
||||||
|
of the capacity it needs before changing any collection. Exhaustion returns
|
||||||
|
`CapacityExceeded`; it does not evict live state, partially insert an operation,
|
||||||
|
or grow a fallback queue. Policy-provided per-owner listing and per-tenant active
|
||||||
|
attempt quotas are evaluated inside the same creation transition, so concurrent
|
||||||
|
requests cannot pass a check performed outside the store. New join authorization returns `ServiceUnavailable`
|
||||||
|
when the atomic store is unavailable and `Draining` once drain starts.
|
||||||
|
|
||||||
|
### Time and cleanup
|
||||||
|
|
||||||
|
Expiry uses an injected monotonic clock. Wall time is used only to return an
|
||||||
|
informational `ExpiresAt` value. Moving the wall clock forward or backward cannot
|
||||||
|
expire or prolong authority. Cleanup runs deterministically at the start of every
|
||||||
|
store operation and removes presence, attempts, listings, replay entries,
|
||||||
|
idempotency records, and revocations at their deadline. Removal of a listing also
|
||||||
|
removes its presence handle and every linked attempt before another caller can
|
||||||
|
observe the store.
|
||||||
|
|
||||||
|
### Concurrency and idempotency
|
||||||
|
|
||||||
|
- Listing registration and join-attempt creation use a tenant-and-owner-scoped idempotency
|
||||||
|
key plus a canonical request fingerprint. An exact duplicate returns the
|
||||||
|
original live result; reuse with different input returns `Conflict`; replay
|
||||||
|
after the resource has expired returns `Expired` until the bounded idempotency
|
||||||
|
record itself expires. Configuration requires idempotency retention to cover
|
||||||
|
every listing and attempt lifetime, preventing a live duplicate after eviction.
|
||||||
|
- Lease renewal is compare-and-swap by version. A stale renewal returns the latest
|
||||||
|
version as `Conflict`. Renew/delete races are serialized: renewal either commits
|
||||||
|
before deletion or observes the listing as absent.
|
||||||
|
- Host presence refresh is an atomic whole-endpoint replacement because NAT
|
||||||
|
mappings can legitimately change. Attempt capabilities are different: the
|
||||||
|
first endpoint bound for each role wins, an identical datagram is idempotent,
|
||||||
|
and a different replay is rejected. Introduction is consumed once atomically.
|
||||||
|
- Cancellation is checked before waiting for the lock and again after acquiring
|
||||||
|
it. A cancellation observed at either point makes no change. Once a synchronous
|
||||||
|
transition starts, it completes atomically and does not expose partial state.
|
||||||
|
|
||||||
|
### Visibility and revocation
|
||||||
|
|
||||||
|
A listing is visible or joinable only when its lease and authenticated UDP host
|
||||||
|
presence are both fresh. Public browsing is tenant/protocol scoped, excludes
|
||||||
|
unlisted sessions, and uses a stable listing-ID order with the contract page
|
||||||
|
ceiling. Revoking a listing or principal removes every listing, presence, and
|
||||||
|
attempt path in the same transition. A revocation is inserted before removal;
|
||||||
|
if the bounded revocation pool is full, the operation rejects without deleting
|
||||||
|
anything.
|
||||||
|
|
||||||
|
### Restart and graceful drain
|
||||||
|
|
||||||
|
A process restart creates a new store instance ID and starts empty. Old listing,
|
||||||
|
lease, attempt, endpoint, idempotency, and consumption state is not recovered.
|
||||||
|
Publishers must re-register; old callers receive typed `NotFound`, `Expired`, or
|
||||||
|
`ServiceUnavailable` outcomes rather than an ambiguous success. No database is
|
||||||
|
required or supported for the single-active MVP.
|
||||||
|
|
||||||
|
Drain is idempotent. It immediately rejects new registrations, attempts, and
|
||||||
|
lease extensions, while already-created attempts may bind endpoints and consume
|
||||||
|
their introduction during the configured window (at most 30 seconds). At the
|
||||||
|
deadline all active state is cleared atomically. Readiness is false while draining
|
||||||
|
or unavailable, and application shutdown starts drain before teardown.
|
||||||
|
|
||||||
|
## Future shared-store mapping
|
||||||
|
|
||||||
|
The interface uses explicit typed outcomes, TTLs, compare-and-swap versions,
|
||||||
|
idempotency records, and all-or-nothing multi-record transitions. A future Redis
|
||||||
|
implementation therefore requires authenticated transport, tenant-prefixed keys,
|
||||||
|
server-side scripts or transactions for each transition, TTLs based on the store's
|
||||||
|
authoritative time, and deterministic mediator routing. It must preserve these
|
||||||
|
semantics and pass the same contract tests before issue #18 may enable more than
|
||||||
|
one active instance.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- V1 has deterministic failure and restart behavior without durable gameplay state.
|
||||||
|
- A single lock is a measured capacity constraint, not a claim of horizontal scale.
|
||||||
|
- Transport and HTTP modules cannot bypass the store for authorization decisions.
|
||||||
|
- Operational code must treat `CapacityExceeded`, `Draining`, and
|
||||||
|
`ServiceUnavailable` as normal typed overload/availability outcomes.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# ADR 0005: authenticated session lease and presence lifecycle
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #7
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A host needs to publish a player-facing session without letting an HTTP request
|
||||||
|
claim a public endpoint or remain visible after the gameplay socket disappears.
|
||||||
|
Registration retries must be safe, credentials must remain opaque, and policy or
|
||||||
|
ownership checks cannot race state mutation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The four host HTTP operations require `Authorization: Bearer <publisher credential>`.
|
||||||
|
The signed principal supplies the authoritative game, environment, publisher trust
|
||||||
|
mode, subject, and allowed regions. Request fields never widen that scope. Creation
|
||||||
|
and update apply the enabled `GamePolicy` to exact protocol, region, visibility,
|
||||||
|
bounded display/build/capacity values, and the allowlisted metadata schema.
|
||||||
|
|
||||||
|
Capacity reported by a host is advisory directory information. Rendezvous bounds
|
||||||
|
and publishes it but never treats it as final admission authority; the game host
|
||||||
|
still decides identity, bans, reserved slots, and whether a connection may join.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> AwaitingPresence: authorized register
|
||||||
|
AwaitingPresence --> Listed: valid host UDP presence
|
||||||
|
Listed --> AwaitingPresence: presence deadline passes
|
||||||
|
AwaitingPresence --> AwaitingPresence: lease renew or data update
|
||||||
|
Listed --> Listed: lease renew, data update, or presence refresh
|
||||||
|
AwaitingPresence --> Removed: lease expiry or delete
|
||||||
|
Listed --> Removed: lease expiry or delete
|
||||||
|
Removed --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
Registration returns a listing ID, lease ID/token, host-presence handle/capability,
|
||||||
|
lease expiry, a 30-second renewal suggestion, and a 10-second presence-refresh
|
||||||
|
suggestion. The authoritative ceilings remain 60 seconds for the lease and 20
|
||||||
|
seconds for presence. Timing suggestions are server-controlled, not client-selected.
|
||||||
|
|
||||||
|
The lease token and presence capability are 256-bit opaque values derived with
|
||||||
|
HMAC-SHA256 from an in-memory per-process secret, a purpose label, the publisher
|
||||||
|
subject, the idempotency key, a canonical request fingerprint, and a random
|
||||||
|
per-registration derivation salt. Opaque IDs use separate purpose labels. Exact
|
||||||
|
retries read the retained non-secret salt and therefore reproduce the original
|
||||||
|
response without retaining plaintext credentials. Once the bounded idempotency
|
||||||
|
record expires, a new salt rotates IDs and capabilities so an old token cannot
|
||||||
|
regain authority. Metadata order is canonicalized before fingerprinting. The store
|
||||||
|
retains the salt and only a second keyed fingerprint of each token. Restart rotates
|
||||||
|
the derivation secret while the matching ephemeral state disappears.
|
||||||
|
|
||||||
|
Renew, update, and delete require both the same publisher subject and the lease
|
||||||
|
capability. Cross-owner or wrong-capability access returns the same not-found shape.
|
||||||
|
Update may change display name, build label, advisory capacity, and metadata only;
|
||||||
|
game, environment, region, protocol, visibility, trust mode, and opaque IDs remain
|
||||||
|
canonical. Delete is idempotent and does not reveal whether another publisher owns
|
||||||
|
the supplied ID.
|
||||||
|
|
||||||
|
### UDP presence
|
||||||
|
|
||||||
|
Only a structurally valid frozen `HostPresence` envelope or native LiteNetLib
|
||||||
|
host-presence request with the issued capability can refresh presence. The public
|
||||||
|
endpoint is the UDP packet's observed source on the host's gameplay socket; the
|
||||||
|
HTTP API never accepts one. The bounded local candidate comes from the authenticated
|
||||||
|
packet. Invalid or unknown inputs receive no response. ADR 0009 defines the later
|
||||||
|
attempt-role use of frozen `ClientPresence` and native host/client requests.
|
||||||
|
Presence expiry demotes public visibility but keeps the lease, so the same handle
|
||||||
|
can restore visibility without changing session identity.
|
||||||
|
|
||||||
|
Public listing responses contain bounded listing data only. They never contain
|
||||||
|
public/local endpoints, lease tokens, presence capabilities, fingerprints, store
|
||||||
|
keys, or canonical player identity.
|
||||||
|
|
||||||
|
## Failure semantics
|
||||||
|
|
||||||
|
- malformed or policy-invalid fields return a stable typed `InvalidRequest`;
|
||||||
|
- an unsupported gameplay protocol returns `IncompatibleProtocol`;
|
||||||
|
- missing/invalid publisher authentication returns `AuthenticationRequired`;
|
||||||
|
- cross-scope authorization returns `Forbidden` without resource disclosure;
|
||||||
|
- wrong owner/capability or expired state returns the tenant-hidden `NotFound`;
|
||||||
|
- idempotency reuse with changed input returns `Conflict`;
|
||||||
|
- publisher/global exhaustion returns `CapacityExceeded`; and
|
||||||
|
- drain or loss of atomic state returns `ServiceUnavailable` and authorizes no join.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- HTTP registration alone can never make a public session browseable.
|
||||||
|
- Plaintext session capabilities are returned to the intended host but are not
|
||||||
|
retained, logged, included in public listing DTOs, or exported as metrics.
|
||||||
|
- Re-registration after restart is the recovery path; there is no durable session
|
||||||
|
identity or gameplay state in Rendezvous.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# ADR 0006: bounded compatible session browser
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #8
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The public list endpoint requires game, environment, and exact gameplay protocol.
|
||||||
|
Region is optional, page size is 1–100, and callers may exclude sessions whose
|
||||||
|
advisory current-player count has reached the advertised maximum. Lists contain
|
||||||
|
public sessions only and only while both lease and authenticated host presence are
|
||||||
|
fresh. Unlisted sessions never appear in a list; they may be retrieved directly by
|
||||||
|
their 128-bit unguessable listing ID only when the caller also supplies the exact
|
||||||
|
game, environment, and protocol scope.
|
||||||
|
|
||||||
|
Results use ascending opaque listing ID as a deterministic keyset. A cursor carries
|
||||||
|
the last ID plus every compatibility/filter field, a five-minute expiry, and an
|
||||||
|
HMAC-SHA256 signature under a per-process key. Tampering, expiry, or reuse with a
|
||||||
|
different tenant/protocol/region/full filter returns `InvalidRequest`. Restart
|
||||||
|
rotates the key, matching the loss of ephemeral listings.
|
||||||
|
|
||||||
|
Pagination is a bounded live view, not a database snapshot. A record that remains
|
||||||
|
eligible and whose ID is greater than the cursor is returned exactly once. Records
|
||||||
|
removed or made stale disappear immediately. A record created after a page whose ID
|
||||||
|
sorts before that page's cursor is outside that traversal; callers refresh from the
|
||||||
|
first page to discover new sessions. This avoids skips or duplicates among stable
|
||||||
|
eligible records without retaining per-browser snapshot state.
|
||||||
|
|
||||||
|
The store reads at most page size plus one record. The service serializes against
|
||||||
|
the 256 KiB response ceiling and shortens a page before returning it when metadata
|
||||||
|
makes the requested count too large. A continuation cursor is emitted whenever an
|
||||||
|
extra or byte-trimmed record remains. All cursor, page, metadata, property, scalar,
|
||||||
|
and collection sizes are bounded before untrusted allocation can grow without a
|
||||||
|
ceiling.
|
||||||
|
|
||||||
|
Browser DTOs are fresh copies containing only opaque listing ID, exact compatibility,
|
||||||
|
region, visibility/trust presentation, advisory capacity, build/display labels, and
|
||||||
|
policy-validated string metadata. They contain no observed endpoint, lease,
|
||||||
|
capability, ticket, credential fingerprint, derivation salt, principal subject, or
|
||||||
|
store key. Metadata is display text: JSON encoding escapes markup, but game UI must
|
||||||
|
still render values as text and must never execute markup, interpret endpoints, or
|
||||||
|
use metadata for authorization.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Cross-game, cross-environment, incompatible, stale, revoked, expired, unlisted,
|
||||||
|
and optionally full sessions are removed before response construction.
|
||||||
|
- Direct unlisted lookup is suitable for an out-of-band invite carrying the opaque
|
||||||
|
ID; human join codes remain future work and require their own bounded abuse model.
|
||||||
|
- Host capacity remains advisory. The host makes the final admission decision.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR 0007: caller-owned .NET publisher and browser SDK
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #9
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The .NET client package exposes separate publisher and browser interfaces plus
|
||||||
|
concrete clients over a caller-supplied `HttpClient`. The caller owns that client,
|
||||||
|
its handler, base address, connection pool, proxy, and lifetime. SDK operations
|
||||||
|
dispose every request, response, and response body they create, but never dispose
|
||||||
|
the supplied client. The package targets `netstandard2.1`, depends only on the
|
||||||
|
wire-contract package and LiteNetLib, and contains no Godot types, global client,
|
||||||
|
service URL, publisher secret, or embedded game credential.
|
||||||
|
|
||||||
|
Every operation returns `RendezvousClientResult<T>` with a stable error code,
|
||||||
|
message, and optional retry guidance. Cancellation remains exceptional through
|
||||||
|
the caller's `CancellationToken`; transport failures become `ServiceUnavailable`.
|
||||||
|
Response bodies are streamed under the contract's 256 KiB browser ceiling before
|
||||||
|
deserialization. Invalid or oversized success bodies become `InternalError` and
|
||||||
|
never escape as partially trusted contract objects.
|
||||||
|
|
||||||
|
The SDK retries only operations whose duplicate execution is safe: scoped reads,
|
||||||
|
idempotency-keyed registration, lease renewal with the same lease token, complete
|
||||||
|
resource update, and lease-token deregistration. It honors bounded server retry
|
||||||
|
guidance and otherwise uses capped exponential backoff with jitter. Each retry
|
||||||
|
creates a fresh HTTP request while preserving the caller's registration
|
||||||
|
idempotency key. Configuration is copied on construction so later option mutation
|
||||||
|
cannot change an in-flight client's behavior.
|
||||||
|
|
||||||
|
`PublishedSession` holds the server-issued lease and presence capabilities needed
|
||||||
|
by the host. Its string representation always redacts them. Update requests are
|
||||||
|
copied before the lease token is attached, so the SDK never mutates caller-owned
|
||||||
|
DTOs. The browser exposes one-page calls and bounded cursor traversal; cursor
|
||||||
|
values remain opaque and caller requests remain unchanged.
|
||||||
|
|
||||||
|
Lease maintenance is explicit. Creating a `SessionLeaseMaintainer` starts no task;
|
||||||
|
the game chooses when to call `RunAsync`, owns cancellation, and awaits
|
||||||
|
`DisposeAsync`. The loop uses the latest server-provided renewal interval and
|
||||||
|
returns a distinct cancelled, disposed, lost-lease, or failed result. Terminal
|
||||||
|
authorization, expiry, and missing-lease responses also raise `LeaseLost` so the
|
||||||
|
host can stop advertising or re-register deliberately.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- SpaceGame and Unscouted can inject the publisher/browser interfaces in tests
|
||||||
|
without an engine runtime or real network.
|
||||||
|
- Games must configure an absolute `HttpClient.BaseAddress` (or equivalent
|
||||||
|
handler routing), obtain publisher credentials from their deployment boundary,
|
||||||
|
and explicitly run and dispose lease maintenance.
|
||||||
|
- The versioned client public-API snapshot and live-server integration tests fail
|
||||||
|
together when SDK and HTTP contracts drift.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR 0008: scoped join attempts and one-time connection tickets
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #10
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Join creation is an unauthenticated public operation because v1 does not treat a
|
||||||
|
Rendezvous caller as game identity. The HTTP source address is normalized and
|
||||||
|
converted to a process-keyed opaque subject for idempotency and bounded policy
|
||||||
|
accounting; raw addresses and the derived subject are never returned or logged.
|
||||||
|
A successful request means only that this network client may try to connect to
|
||||||
|
this active session. It does not reserve capacity or grant gameplay admission.
|
||||||
|
|
||||||
|
Creation validates the v1 contract, caller idempotency key, enabled tenant policy,
|
||||||
|
exact gameplay protocol, listing scope, live lease, and fresh authenticated host
|
||||||
|
presence in one atomic store operation. A listing advertised as full remains
|
||||||
|
joinable because its player count is advisory and the game host owns the final
|
||||||
|
capacity, identity, ban, and admission decision.
|
||||||
|
|
||||||
|
Each attempt derives independent host-punch, client-punch, and connection-ticket
|
||||||
|
credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC
|
||||||
|
key, the client subject, the complete canonical request fingerprint, a fresh salt,
|
||||||
|
and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
|
||||||
|
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
|
||||||
|
256-character NAT token ceiling. The connection ticket uses half of that payload
|
||||||
|
for its attempt ID and half for an independently derived 128-bit authenticator, so
|
||||||
|
the SDK can correlate concurrent introductions without increasing UDP response
|
||||||
|
size. State retains keyed credential fingerprints, derivation inputs, and salt—not
|
||||||
|
issued plaintext. All diagnostic string representations redact credentials and
|
||||||
|
derivation material.
|
||||||
|
|
||||||
|
The client receives only its punch capability. A host polls its own listing with
|
||||||
|
the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
|
||||||
|
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
|
||||||
|
identical join request returns the same live attempt; changing the request under
|
||||||
|
the same owner/key conflicts. A client may cancel with its punch capability in
|
||||||
|
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically marks the attempt
|
||||||
|
and retains a bounded tombstone until its original expiry. Host polling returns
|
||||||
|
that tombstone so a coordinator can revoke any local ticket authorization, while
|
||||||
|
endpoint binding, introduction, ticket issuance, and ticket consumption all
|
||||||
|
reject the cancelled attempt. Listing deletion, expiry, revocation, or process
|
||||||
|
restart removes every associated attempt and credential fingerprint.
|
||||||
|
|
||||||
|
Endpoint binding remains role- and capability-specific. The first endpoint
|
||||||
|
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
|
||||||
|
endpoint or role substitution is rejected. An introduction is consumable once
|
||||||
|
only after both roles bind, so concurrent attempts for the same listing cannot
|
||||||
|
cross-wire.
|
||||||
|
|
||||||
|
The connection ticket is distinct from both punch capabilities and is reproduced
|
||||||
|
only after introduction succeeds. Its window begins at that moment and lasts at
|
||||||
|
most 20 seconds without outliving the 30-second attempt. The server has an atomic
|
||||||
|
fingerprint-consumption seam for mediator tests and revocation. On the game host,
|
||||||
|
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
|
||||||
|
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/
|
||||||
|
revoked/replayed tickets, and zeroes retained digests and key material on disposal.
|
||||||
|
Issue #11 carries the fixed-size ticket in the authenticated introduction. Issue
|
||||||
|
#12 extracts its embedded attempt ID, bounds the host's local authorization window
|
||||||
|
by both the host-polled attempt expiry and the configured ticket lifetime, then
|
||||||
|
wires one-time consumption into the caller-owned coordinator. Both peers receive
|
||||||
|
a digest of the exact expected ticket over HTTP and reject any syntactically valid
|
||||||
|
but unauthenticated introduction token. Embedding the ID prevents concurrent or
|
||||||
|
late introductions from cross-binding a valid ticket while preserving the
|
||||||
|
mediator's 2.0 response-byte amplification ceiling.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A join attempt is transport authorization, never proof of player identity or a
|
||||||
|
game slot.
|
||||||
|
- Network-address-derived subjects are process-local abuse/idempotency scopes,
|
||||||
|
not stable user identifiers; stronger authenticated player scopes require a
|
||||||
|
future game-owned identity contract.
|
||||||
|
- Cancellation after a ticket has reached a host must also revoke that host's
|
||||||
|
local validator entry; coordinator wiring owns that race in issue #12.
|
||||||
|
- Capability and ticket plaintext never enter browser results, state snapshots,
|
||||||
|
logs, metrics, or generated string representations.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0009: authenticated bounded LiteNetLib NAT mediator
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #11
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The server owns one LiteNetLib `NetManager` and its `NatPunchModule` on the
|
||||||
|
configured UDP endpoint. It runs in manual mode with a configured maximum number
|
||||||
|
of datagrams per poll and a short caller-owned poll interval. LiteNetLib events
|
||||||
|
are unsynchronized so authenticated requests are processed immediately on that
|
||||||
|
single polling path rather than accumulated in an unbounded event queue. The
|
||||||
|
mediator never accepts a LiteNetLib gameplay connection or handles application
|
||||||
|
payloads.
|
||||||
|
|
||||||
|
The packet layer also consumes the frozen v1 presence envelope on the same
|
||||||
|
socket. Native NAT requests use a canonical fixed-size 192-character token that
|
||||||
|
binds a role (`HostPresence`, attempt `Host`, or attempt `Client`), mediation
|
||||||
|
handle, and the already-issued capability. Both transports enter one processor
|
||||||
|
and the same atomic store operations. No transport-supplied public address is
|
||||||
|
trusted; the socket source is authoritative.
|
||||||
|
|
||||||
|
LiteNetLib's native NAT packet family also contains introduction-response and
|
||||||
|
punch frames that are appropriate for peers but unsafe on a public mediator: a
|
||||||
|
forged response can name arbitrary destinations. The packet layer therefore
|
||||||
|
decodes only the pinned `NatIntroduceRequest` wire shape and consumes every
|
||||||
|
inbound packet before `NatPunchModule` sees it. The module is outbound-only and
|
||||||
|
may send introductions solely from a completed authorized plan.
|
||||||
|
|
||||||
|
Listing presence refreshes authorize no response. Attempt contributions bind the
|
||||||
|
first observed endpoint for exactly one capability role. Exact duplicates are
|
||||||
|
idempotent; a different endpoint, the opposite role, an expired/cancelled
|
||||||
|
attempt, or a stale listing presence cannot replace it. The introduction is
|
||||||
|
consumed atomically only after both roles bind and their observed address
|
||||||
|
families match, preventing concurrent attempts for one listing from cross-wiring.
|
||||||
|
|
||||||
|
A reported local candidate is eligible only when it is RFC 1918 IPv4 or IPv6
|
||||||
|
unique-local unicast, matches the observed family, and both peers have the same
|
||||||
|
observed public address. Otherwise `NatIntroduce` receives the observed public
|
||||||
|
endpoint in the local slot. Loopback, link-local, multicast, unspecified,
|
||||||
|
documentation IPv6, global-address claims, and cross-family claims are never
|
||||||
|
disclosed as local targets. IPv4 is required; observed global IPv6 can be used
|
||||||
|
when both peers contribute IPv6, without claiming guaranteed IPv6 NAT traversal.
|
||||||
|
|
||||||
|
The introduction carries only the distinct connection ticket and is emitted at
|
||||||
|
most once to each verified observed endpoint. The fixed authenticated native
|
||||||
|
request and bounded frozen envelope keep the combined response bytes within the
|
||||||
|
2.0 verified amplification budget; unauthenticated inputs receive zero bytes.
|
||||||
|
Malformed, truncated, oversized, spoofed, or unrelated LiteNetLib packets do not
|
||||||
|
grow Rendezvous state. Raw endpoints and credentials are never logged or exposed
|
||||||
|
through diagnostic string representations.
|
||||||
|
|
||||||
|
Frozen IPv6 listing-presence refresh remains valid because it emits no response.
|
||||||
|
IPv6 attempt roles require the fixed-size native LiteNetLib request; accepting the
|
||||||
|
short frozen envelope would exceed the 2.0 byte budget for two IPv6 introduction
|
||||||
|
frames. The required IPv4 listen address and optional IPv6 listen address are
|
||||||
|
configured separately so enabling one family never widens the other family to a
|
||||||
|
wildcard bind.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Hosts refresh listing presence and answer invitations from their actual
|
||||||
|
gameplay socket; a separate mediator socket would observe the wrong mapping.
|
||||||
|
- Caller-owned SDK coordination in #12 must poll the host invitation endpoint,
|
||||||
|
send the corresponding native role token, and consume the returned ticket.
|
||||||
|
- UDP loss can prevent traversal, but it cannot cause an arbitrary destination,
|
||||||
|
replay, role substitution, or cross-attempt introduction.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-16
|
||||||
|
- Tracking: #13
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A connection can stop in the directory, authorization, mediation, NAT traversal,
|
||||||
|
or direct-connection phase. Those failures have different authorities: an HTTP
|
||||||
|
response can authoritatively reject a join, the SDK can observe a local timeout,
|
||||||
|
and only the remote host can reject a direct connection. Treating all of them as
|
||||||
|
one message or generic timeout would make player guidance, retry policy, tests,
|
||||||
|
and operational measurements unreliable.
|
||||||
|
|
||||||
|
UDP loss, service silence, cancellation, and late LiteNetLib callbacks also make
|
||||||
|
completion races unavoidable. Games need one terminal result and bounded work,
|
||||||
|
not a sequence of contradictory callbacks. Direct traversal cannot be guaranteed,
|
||||||
|
but v1 has no gameplay relay and must not imply otherwise.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Closed typed outcome model
|
||||||
|
|
||||||
|
`ConnectionOutcomeKind` is the stable wire-level terminal set: connected,
|
||||||
|
cancelled, directory not found, attempt expired, incompatible protocol,
|
||||||
|
unauthorized, rate limited, no host presence, service unavailable or rejected,
|
||||||
|
mediator unavailable, punch timeout, direct-connect timeout, host rejection,
|
||||||
|
transport error, manager stopped, and disposed.
|
||||||
|
|
||||||
|
The already-frozen v1 members `TimedOut`, `StaleHost`, `TransportFailed`, and
|
||||||
|
`FallbackOffered` retain their original numeric values for source and wire
|
||||||
|
compatibility. New SDK code never emits them. The report service accepts them,
|
||||||
|
normalizes the first three to their precise modern equivalents, and does not let
|
||||||
|
legacy compatibility weaken the typed coordinator result.
|
||||||
|
|
||||||
|
The client adds `RendezvousConnectionOutcomeSource`, failure category, and phase.
|
||||||
|
These fields preserve authority instead of guessing from text:
|
||||||
|
|
||||||
|
- `RendezvousService` is used only for an HTTP decision or bounded service
|
||||||
|
silence. Its optional `ServiceError` retains the stable service error code.
|
||||||
|
- `LocalTraversal` reports local punch, direct-connect, and transport
|
||||||
|
observations.
|
||||||
|
- `RemoteHost` reports an explicit direct-connection rejection.
|
||||||
|
- `Caller` and `Lifecycle` distinguish cancellation from manager shutdown or
|
||||||
|
disposal.
|
||||||
|
|
||||||
|
Messages remain diagnostic and are never parsed into outcomes. A successful NAT
|
||||||
|
introduction is only a transition to direct connection; `Connected` is emitted
|
||||||
|
only after LiteNetLib reports the authenticated peer connected.
|
||||||
|
|
||||||
|
Join issuance is exposed as `RendezvousConnectionStartResult`, containing exactly
|
||||||
|
one issued attempt or one terminal service outcome. Once an attempt is issued,
|
||||||
|
the coordinator owns its local terminal outcome. Completion is exactly once;
|
||||||
|
terminal paths release SDK subscriptions so late introductions, peer callbacks,
|
||||||
|
network errors, cancellation, and polling are inert.
|
||||||
|
|
||||||
|
### Bounded phases and retries
|
||||||
|
|
||||||
|
Each HTTP try has a five-second default silence budget, configurable from above
|
||||||
|
zero through thirty seconds. Only safe operations use the existing bounded retry
|
||||||
|
policy, honoring caller cancellation and server retry guidance. Exhausting that
|
||||||
|
budget returns `ServiceUnavailable`; it never waits indefinitely.
|
||||||
|
|
||||||
|
Traversal has independent defaults: ten seconds for punch/mediation and five
|
||||||
|
seconds for the direct connection. Both are configurable up to thirty seconds.
|
||||||
|
Local budgets, retry schedules, and elapsed duration use monotonic time, so a
|
||||||
|
wall-clock correction cannot extend them or produce a negative duration. The
|
||||||
|
signed attempt expiry is converted to an additional monotonic upper bound when
|
||||||
|
the attempt is received. Punch retries retain
|
||||||
|
their bounded request count and exponential backoff; crossing a phase deadline
|
||||||
|
completes exactly once even if a delayed packet later arrives. Tests use an
|
||||||
|
injected clock and do not depend on wall-clock sleeps.
|
||||||
|
|
||||||
|
### Explicit dedicated fallback handoff
|
||||||
|
|
||||||
|
A publisher may attach one validated dedicated endpoint to registration or
|
||||||
|
update only when the tenant's provisioned fallback policy allows it. The server
|
||||||
|
copies that endpoint into browser and issued-attempt contracts.
|
||||||
|
The client coordinator defensively copies it into every terminal outcome; a game
|
||||||
|
may override it locally through `DedicatedFallbackOverride`.
|
||||||
|
|
||||||
|
The SDK never opens, dials, reserves, probes, or authenticates the fallback. The
|
||||||
|
game decides whether the outcome permits fallback, presents any player choice,
|
||||||
|
and connects through its own gameplay transport and admission rules. Absence of
|
||||||
|
an endpoint is an honest no-fallback result. Gameplay relay is absent from v1.
|
||||||
|
|
||||||
|
### Privacy-safe optional reporting
|
||||||
|
|
||||||
|
After an issued attempt completes, the game may explicitly report its outcome
|
||||||
|
with the short-lived client punch capability. Reporting is authenticated and
|
||||||
|
idempotent: an exact repeat succeeds as a duplicate, while a conflicting repeat
|
||||||
|
is rejected. Reports contain only an allowlisted outcome enum and one coarse
|
||||||
|
elapsed bucket (`<1s`, `1–5s`, `5–15s`, `15–30s`, or `30s+`). They contain no
|
||||||
|
diagnostic message, exact duration, endpoint, metadata, player identifier, or
|
||||||
|
credential.
|
||||||
|
|
||||||
|
Frozen v1 DTOs still expose `elapsedMilliseconds` and `diagnosticCode`. They are
|
||||||
|
deprecated compatibility inputs: the current SDK omits them, the service
|
||||||
|
immediately buckets legacy elapsed time, and neither exact timing nor diagnostic
|
||||||
|
text is retained, logged, or used as a metric dimension.
|
||||||
|
|
||||||
|
The store retains a bounded capability-fingerprint tombstone long enough to
|
||||||
|
accept a report after the live attempt expires. Metrics count the first accepted
|
||||||
|
outcome only and use only outcome plus elapsed bucket as dimensions. Service
|
||||||
|
issuance failures cannot be reported because no attempt capability was issued.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Player-facing UI can map stable outcome/category pairs to localized guidance
|
||||||
|
without exposing diagnostic strings.
|
||||||
|
- Service rejection, remote-host rejection, and local observation remain
|
||||||
|
distinguishable for retry and support decisions.
|
||||||
|
- Games own fallback policy and gameplay admission; Rendezvous does not claim a
|
||||||
|
guaranteed connection path.
|
||||||
|
- Outcome additions are contract changes and require OpenAPI, serialization,
|
||||||
|
public API, fake-clock, late-event, and idempotency coverage.
|
||||||
@@ -6,6 +6,13 @@ decision requires a superseding ADR and corresponding contract/test updates.
|
|||||||
- [ADR 0001: v1 control-plane boundaries and domain](0001-v1-control-plane-boundaries.md)
|
- [ADR 0001: v1 control-plane boundaries and domain](0001-v1-control-plane-boundaries.md)
|
||||||
- [ADR 0002: publisher trust, discovery, compatibility, and fallback](0002-publisher-trust-and-connection-policy.md)
|
- [ADR 0002: publisher trust, discovery, compatibility, and fallback](0002-publisher-trust-and-connection-policy.md)
|
||||||
- [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
|
- [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
|
||||||
|
- [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md)
|
||||||
|
- [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md)
|
||||||
|
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md)
|
||||||
|
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
|
||||||
|
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
|
||||||
|
- [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md)
|
||||||
|
- [ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback](0010-typed-connection-outcomes-and-fallback.md)
|
||||||
- [Threat model](../security/threat-model.md)
|
- [Threat model](../security/threat-model.md)
|
||||||
- [Security promise and test matrix](../security/control-matrix.md)
|
- [Security promise and test matrix](../security/control-matrix.md)
|
||||||
- [Versioned HTTP and UDP contracts](../contracts/README.md)
|
- [Versioned HTTP and UDP contracts](../contracts/README.md)
|
||||||
|
|||||||
@@ -33,15 +33,14 @@ the same value as a required query parameter.
|
|||||||
| `GET` | `/v1/sessions` | Browse compatible public sessions. |
|
| `GET` | `/v1/sessions` | Browse compatible public sessions. |
|
||||||
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
|
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
|
||||||
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
|
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
|
||||||
|
| `DELETE` | `/v1/join-attempts/{attemptId}` | Cancel an attempt using its client punch capability. |
|
||||||
| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
|
| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
|
||||||
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
|
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
|
||||||
| `GET` | `/health/live` | Report that the HTTP process is alive. |
|
| `GET` | `/health/live` | Report that the HTTP process is alive. |
|
||||||
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
|
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
|
||||||
|
|
||||||
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
|
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
|
||||||
shape reference for parameters, bodies, and responses. Contract-only endpoints
|
shape reference for parameters, bodies, and responses.
|
||||||
return `501` until their behavior is implemented by the subsequent directory,
|
|
||||||
lease, and join-orchestration issues.
|
|
||||||
|
|
||||||
Host polling sends its reusable lease credential in
|
Host polling sends its reusable lease credential in
|
||||||
`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
|
`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
|
||||||
@@ -49,6 +48,29 @@ for mutation operations are carried in their request bodies. Public browser
|
|||||||
responses contain no IP endpoints, lease tokens, punch capabilities, connection
|
responses contain no IP endpoints, lease tokens, punch capabilities, connection
|
||||||
tickets, player identifiers, or gameplay state.
|
tickets, player identifiers, or gameplay state.
|
||||||
|
|
||||||
|
Attempt cancellation sends the short-lived client punch capability in
|
||||||
|
`X-Rendezvous-Client-Punch-Capability`. Join creation uses the observed HTTP
|
||||||
|
source only for a process-keyed, short-lived idempotency/abuse scope; this is not
|
||||||
|
player authentication and is never returned to callers.
|
||||||
|
|
||||||
|
Outcome reporting uses that same short-lived capability. It accepts only outcomes
|
||||||
|
for an issued attempt and carries one stable outcome enum plus one coarse elapsed
|
||||||
|
bucket. Exact duplicate reports are idempotent; conflicting repeats fail. Reports
|
||||||
|
never carry exact timing, diagnostics, endpoints, metadata, player identifiers,
|
||||||
|
or credentials.
|
||||||
|
|
||||||
|
The frozen v1 .NET request also retains deprecated `elapsedMilliseconds` and
|
||||||
|
`diagnosticCode` properties for source/wire compatibility. Current clients omit
|
||||||
|
them. If a legacy client supplies them, the server immediately converts elapsed
|
||||||
|
milliseconds to the coarse bucket and discards diagnostic text; neither value is
|
||||||
|
retained or used as a metric dimension.
|
||||||
|
|
||||||
|
Registration and update may include one validated `dedicatedFallback`. The
|
||||||
|
endpoint must be enabled by the tenant's provisioned fallback policy, is visible
|
||||||
|
browser data, and is copied into subsequently issued attempts.
|
||||||
|
It is a handoff for caller-owned policy: neither the HTTP service nor the SDK
|
||||||
|
automatically connects to it. V1 provides no gameplay relay.
|
||||||
|
|
||||||
## Idempotency, cursors, and retries
|
## Idempotency, cursors, and retries
|
||||||
|
|
||||||
Registration and join creation require a caller-generated visible-ASCII
|
Registration and join creation require a caller-generated visible-ASCII
|
||||||
@@ -95,9 +117,9 @@ must not be parsed. Secrets and raw credentials are never echoed.
|
|||||||
| 401 | `authenticationRequired` |
|
| 401 | `authenticationRequired` |
|
||||||
| 403 | `forbidden` |
|
| 403 | `forbidden` |
|
||||||
| 404 | `notFound` |
|
| 404 | `notFound` |
|
||||||
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` |
|
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected` |
|
||||||
| 410 | `expired`, `staleHost` |
|
| 410 | `expired`, `staleHost` |
|
||||||
| 429 | `rateLimited` (with retry guidance when known) |
|
| 429 | `rateLimited`, `capacityExceeded` (with retry guidance when known) |
|
||||||
| 503 | `serviceUnavailable` (with retry guidance when known) |
|
| 503 | `serviceUnavailable` (with retry guidance when known) |
|
||||||
| 500 | `internalError` |
|
| 500 | `internalError` |
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# UDP presence contract v1
|
# UDP presence and NAT-punch contract v1
|
||||||
|
|
||||||
Tracking: #4
|
Tracking: #4, #11
|
||||||
|
|
||||||
The UDP mediator accepts a single bounded presence envelope from a host or
|
The UDP mediator accepts the frozen bounded presence envelope below and native
|
||||||
client. It associates the authenticated mediation handle with the packet's
|
LiteNetLib NAT-introduction requests. Both forms associate an authenticated
|
||||||
observed public source endpoint and the sender's reported local endpoint. It
|
mediation handle with the packet's observed public source endpoint and the
|
||||||
does not carry gameplay packets.
|
sender's reported local endpoint. Neither form carries gameplay packets.
|
||||||
|
|
||||||
All multi-byte integers use network byte order. UUID bytes use the canonical
|
All multi-byte integers use network byte order. UUID bytes use the canonical
|
||||||
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
|
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
|
||||||
@@ -49,5 +49,48 @@ Capabilities are short-lived, single-purpose, scoped to one mediation handle,
|
|||||||
and compared without exposing them in logs. A valid-looking packet does not
|
and compared without exposing them in logs. A valid-looking packet does not
|
||||||
prove authorization until the capability is checked. Invalid packets receive
|
prove authorization until the capability is checked. Invalid packets receive
|
||||||
no UDP response, preventing the mediator from becoming an amplification oracle.
|
no UDP response, preventing the mediator from becoming an amplification oracle.
|
||||||
Replay, expiry, pairing, and rate-limit policy are defined by later mediator
|
For the frozen envelope, `HostPresence` is resolved against either the listing's
|
||||||
issues; the v1 envelope deliberately leaves no unbounded or reflected payload.
|
host-presence capability or an attempt's host-role capability. `ClientPresence`
|
||||||
|
is resolved only against the attempt's client-role capability. Handles are
|
||||||
|
globally distinct in the active store, so this does not permit role confusion.
|
||||||
|
|
||||||
|
## Native LiteNetLib request token
|
||||||
|
|
||||||
|
A game using LiteNetLib sends `NatPunchModule.SendNatIntroduceRequest` from its
|
||||||
|
gameplay `NetManager`. The `additionalInfo` value is produced by
|
||||||
|
`NatPunchRequestTokenCodec` and is exactly 192 ASCII characters:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rv1:<role>:<32 lowercase handle hex>:<43-character capability><dot padding>
|
||||||
|
```
|
||||||
|
|
||||||
|
`role` is `p` for listing host-presence refresh, `h` for the host side of a join
|
||||||
|
attempt, or `c` for its client side. Padding is canonical and leaves the token
|
||||||
|
below LiteNetLib's 256-character ceiling. Its fixed size also ensures that the
|
||||||
|
two authenticated introduction responses remain within the 2.0 response-byte
|
||||||
|
budget. Tokens with a wrong length, role, handle, capability, or padding receive
|
||||||
|
no response.
|
||||||
|
|
||||||
|
The mediator runs LiteNetLib in bounded manual-poll mode. Its packet layer admits
|
||||||
|
only the pinned native `NatIntroduceRequest` frame, consumes every inbound frame
|
||||||
|
before LiteNetLib can act on it, and uses `NatPunchModule` only to emit authorized
|
||||||
|
introductions. Native and frozen v1 inputs reach the same atomic role/capability
|
||||||
|
checks. Only the packet source is
|
||||||
|
used as the public endpoint. A claimed private candidate is retained only when
|
||||||
|
it is private unicast, matches the observed address family, and both authorized
|
||||||
|
peers were observed behind the same public address; otherwise the observed
|
||||||
|
public endpoint is substituted. IPv4 punching is required. IPv6 sources must be
|
||||||
|
observed global unicast and both roles must use IPv6; IPv6 NAT traversal remains
|
||||||
|
best-effort rather than a v1 release requirement.
|
||||||
|
|
||||||
|
The second valid contribution atomically consumes the introduction and starts
|
||||||
|
the connection-ticket lifetime. `NatIntroduce` is called once with the distinct
|
||||||
|
43-character connection ticket. Reordered and exact duplicate requests are
|
||||||
|
idempotent. Endpoint substitution, cross-role use, stale host presence, expired
|
||||||
|
or cancelled attempts, malformed packets, and gameplay payloads produce no
|
||||||
|
introduction and create no mediator queue or endpoint state.
|
||||||
|
|
||||||
|
Frozen envelopes may refresh listing presence over IPv6 because that operation
|
||||||
|
has no response. IPv6 attempt contributions must use the fixed-size native token;
|
||||||
|
the shorter frozen IPv6 envelope cannot fund two IPv6 introduction frames within
|
||||||
|
the 2.0 response-byte ceiling and is therefore dropped without response.
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# Secure single-active Linux deployment
|
||||||
|
|
||||||
|
Tracking: #17
|
||||||
|
|
||||||
|
Rendezvous v1 stores listings, observed endpoints, join attempts, replay markers,
|
||||||
|
and runtime revocations only in the process that accepted them. Deploy exactly
|
||||||
|
one active instance. A second live replica would have a different directory and
|
||||||
|
replay boundary; `SingleActiveInstance=false` is therefore rejected rather than
|
||||||
|
presented as high availability.
|
||||||
|
|
||||||
|
## Pinned container
|
||||||
|
|
||||||
|
The root `Dockerfile` uses a multi-stage .NET 10 build and pins both Microsoft
|
||||||
|
base images by multi-architecture manifest digest. The runtime is the chiseled
|
||||||
|
ASP.NET image, contains only the published server, runs as UID/GID 1654, exposes
|
||||||
|
TCP 8080 and UDP 9050 explicitly, and does not require a writable application
|
||||||
|
directory. Supply a small writable `/tmp` tmpfs because runtime libraries can
|
||||||
|
legitimately need temporary space; keep the root filesystem read-only.
|
||||||
|
|
||||||
|
From a clean checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --pull=false --tag finalfactory/rendezvous:local .
|
||||||
|
docker inspect --format '{{.Config.User}}' finalfactory/rendezvous:local
|
||||||
|
```
|
||||||
|
|
||||||
|
The reported user must be `1654:1654`. Digest pins make a rebuild reproducible;
|
||||||
|
updating .NET is an explicit reviewed change to the tag, digest, SDK pin, and
|
||||||
|
lock files together. Do not replace the digest with `latest` in production.
|
||||||
|
|
||||||
|
The local Compose example applies a read-only root, non-root user, no Linux
|
||||||
|
capabilities, `no-new-privileges`, bounded PIDs/files/memory/CPU, and a shutdown
|
||||||
|
grace period longer than the service drain deadline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -d -m 0700 deploy/compose/secrets
|
||||||
|
umask 077
|
||||||
|
openssl rand -out deploy/compose/secrets/signing-key 32
|
||||||
|
export RENDEZVOUS_UID="$(id -u)"
|
||||||
|
export RENDEZVOUS_GID="$(id -g)"
|
||||||
|
test "$RENDEZVOUS_UID" -ne 0
|
||||||
|
docker compose -f deploy/compose/compose.yaml up --build --detach
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy/compose/appsettings.Production.json` is an isolated loopback smoke
|
||||||
|
profile, not an Internet template: it deliberately opts into private advertised
|
||||||
|
endpoints and has no TLS proxy. Its random key is ignored by Git and must be
|
||||||
|
deleted after use. Its deliberately long key window only keeps this disposable
|
||||||
|
local fixture usable; production keys require short, reviewed rotation windows.
|
||||||
|
Production configuration must use its real public names and must leave
|
||||||
|
`AllowPrivatePublicEndpoints` false.
|
||||||
|
|
||||||
|
## Production topology
|
||||||
|
|
||||||
|
Use one active service behind a source-preserving edge:
|
||||||
|
|
||||||
|
```text
|
||||||
|
clients -- HTTPS/443 --> TLS reverse proxy -- HTTP/8080 --> Rendezvous
|
||||||
|
clients -- UDP/9050 -------------------------------------> Rendezvous
|
||||||
|
```
|
||||||
|
|
||||||
|
- Give the HTTPS origin and UDP endpoint stable DNS names. Set
|
||||||
|
`PublicHttpBaseUrl` to the exact external HTTPS origin and `PublicUdpHost` /
|
||||||
|
`PublicUdpPort` to the endpoint given to game clients.
|
||||||
|
- Terminate TLS 1.2 or newer at a maintained reverse proxy. Bind internal HTTP
|
||||||
|
only to the private proxy network. Restrict `AllowedHosts` to the public HTTP
|
||||||
|
host; wildcard host filtering is rejected.
|
||||||
|
- Put only the proxy's exact literal addresses in
|
||||||
|
`Rendezvous:AbuseProtection:TrustedProxyAddresses`. Rendezvous ignores
|
||||||
|
forwarded headers from every other source. Keep the last proxy from replacing
|
||||||
|
the original client address and prevent direct access to TCP 8080.
|
||||||
|
- Forward UDP as UDP, without an HTTP proxy. NAT, load balancer, firewall, and
|
||||||
|
return routing must preserve the client's source IP/port and must send replies
|
||||||
|
from the same advertised IP/port. Many HTTP load balancers, Kubernetes ingress
|
||||||
|
controllers, rootless container port proxies, anycast products, and generic
|
||||||
|
L7 services cannot guarantee this. Do not deploy through one unless an actual
|
||||||
|
host/join smoke proves both observed source and reply path. A load balancer
|
||||||
|
must have exactly one healthy Rendezvous target.
|
||||||
|
- Permit inbound TCP 443 to the TLS proxy and UDP 9050 to Rendezvous. Permit the
|
||||||
|
proxy to reach TCP 8080. Permit DNS, time synchronization, image/telemetry
|
||||||
|
destinations as required by local policy, and UDP replies to client endpoints.
|
||||||
|
Deny public TCP 8080 and every unused inbound port.
|
||||||
|
|
||||||
|
Readiness is the load-balancer gate; liveness is only a process-health signal.
|
||||||
|
Remove a draining instance from new traffic when `/health/ready` becomes 503.
|
||||||
|
Do not use liveness failure to start a second active process while the old one
|
||||||
|
still owns the public UDP address.
|
||||||
|
|
||||||
|
## Required production configuration
|
||||||
|
|
||||||
|
Production startup validates all of these before binding listeners:
|
||||||
|
|
||||||
|
- an absolute path-free HTTPS `PublicHttpBaseUrl`;
|
||||||
|
- an unambiguous public `PublicUdpHost` and port;
|
||||||
|
- `SingleActiveInstance=true`, an explicit non-wildcard `AllowedHosts`, and at
|
||||||
|
least one exact trusted TLS-proxy address;
|
||||||
|
- a 1-30 second drain deadline whose minimum observation interval is shorter;
|
||||||
|
- at least one enabled game policy and an active scoped signing key.
|
||||||
|
|
||||||
|
Missing values produce an actionable startup error. The checked-in base file is
|
||||||
|
intentionally unsafe for Production so an accidental bare launch fails closed.
|
||||||
|
|
||||||
|
Signing keys support two external references:
|
||||||
|
|
||||||
|
- `env:NAME` reads 1-4096 bytes encoded as base64 from `NAME`;
|
||||||
|
- `file:/absolute/path` reads 1-4096 raw bytes from a non-symlink file.
|
||||||
|
|
||||||
|
Prefer a read-only container secret owned by the configured container identity.
|
||||||
|
For systemd, use a root-owned, `rendezvous`-group-owned `0440` file (or an
|
||||||
|
equivalent narrow ACL) so the non-root process can read but not replace it. A
|
||||||
|
signing key must contain at least 32 random bytes. Never put the key, publisher/operator
|
||||||
|
credential, or secret value in JSON, a command argument, an image layer, Compose
|
||||||
|
environment, logs, metrics, or source control. Configuration contains only the
|
||||||
|
reference and non-secret lifecycle metadata. A vault/KMS adapter can replace the
|
||||||
|
provider where local policy requires it.
|
||||||
|
|
||||||
|
Keep the host clock synchronized with authenticated NTP. Credential and key
|
||||||
|
windows use wall time; lease, timeout, drain, and rate-limit deadlines use a
|
||||||
|
monotonic clock. Alert on clock synchronization loss before rotating keys.
|
||||||
|
|
||||||
|
The checked-in Compose limits (one CPU and 512 MiB) are for its isolated smoke
|
||||||
|
profile, not a production capacity claim. The measured core-state candidate
|
||||||
|
uses 2 vCPU and 2 GiB with the same 128-PID/4096-descriptor ceilings; see
|
||||||
|
the [capacity and resilience gate](../operations/capacity-and-resilience.md).
|
||||||
|
Measure real traffic, then change resource limits and server budgets together.
|
||||||
|
Memory pressure or CPU throttling must not extend orchestrator termination past
|
||||||
|
`DrainDeadlineSeconds` plus five seconds.
|
||||||
|
|
||||||
|
## Graceful shutdown
|
||||||
|
|
||||||
|
SIGTERM and the authenticated operator drain both stop new registrations and
|
||||||
|
join attempts immediately. On process shutdown, HTTP and UDP remain available
|
||||||
|
long enough for existing join attempts to finish. The service exits as soon as
|
||||||
|
the minimum drain interval has elapsed and no attempts remain, or forcibly
|
||||||
|
clears all ephemeral state at the configured deadline. It then stops UDP and
|
||||||
|
HTTP listeners and exits. Configure Docker/systemd/Kubernetes termination grace
|
||||||
|
strictly longer than the service deadline; the examples use 40 seconds for a
|
||||||
|
30-second drain.
|
||||||
|
|
||||||
|
Never use SIGKILL for a normal rollout. After stopping, verify the process is
|
||||||
|
gone and neither `8080/tcp` nor `9050/udp` is bound before starting its
|
||||||
|
replacement on the same host. A crashed or force-killed process cannot drain;
|
||||||
|
clients recover through bounded retries and hosts re-register.
|
||||||
|
|
||||||
|
## systemd alternative
|
||||||
|
|
||||||
|
Publish the server for Linux, install the immutable output at `/opt/rendezvous`,
|
||||||
|
place production configuration beside the application read-only, place key
|
||||||
|
files below `/etc/rendezvous`, and install `deploy/systemd/rendezvous.service`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet publish src/FinalFactory.Rendezvous.Server \
|
||||||
|
--configuration Release --runtime linux-x64 --self-contained false \
|
||||||
|
--output publish/rendezvous
|
||||||
|
systemd-analyze verify deploy/systemd/rendezvous.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now rendezvous.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the dedicated `rendezvous` user without a login shell. Keep
|
||||||
|
`/opt/rendezvous` and `/etc/rendezvous` root-owned and non-writable by that user;
|
||||||
|
install each required key with `root:rendezvous` ownership and mode `0440`. The
|
||||||
|
unit applies the measured 2-vCPU/2-GiB core-state candidate profile plus the
|
||||||
|
same filesystem, privilege, network-family, and shutdown hardening as Compose.
|
||||||
|
|
||||||
|
## HTTP and UDP smoke
|
||||||
|
|
||||||
|
Build the diagnostic once, then exercise the actual published HTTP and UDP
|
||||||
|
paths. The test creates a public listing, sends authenticated presence and punch
|
||||||
|
traffic through UDP 9050, establishes peer-to-peer traffic, reports the outcome,
|
||||||
|
and deregisters cleanly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
||||||
|
./scripts/smoke-deployment.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For the local Compose profile, the script derives a ten-minute diagnostic
|
||||||
|
publisher credential from the ignored local key without printing either secret.
|
||||||
|
For production, do not copy the signing key to the smoke host. Instead inject a
|
||||||
|
short-lived, region-scoped credential through
|
||||||
|
`RENDEZVOUS_PUBLISHER_CREDENTIAL`, and set the external endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<short-lived deployment credential>'
|
||||||
|
export RENDEZVOUS_SMOKE_HTTP_URL='https://rendezvous.your-company.tld/'
|
||||||
|
export RENDEZVOUS_SMOKE_UDP_ENDPOINT='rendezvous-udp.your-company.tld:9050'
|
||||||
|
export RENDEZVOUS_SMOKE_GAME_ID='<credential game ID>'
|
||||||
|
export RENDEZVOUS_SMOKE_ENVIRONMENT_ID='<credential environment ID>'
|
||||||
|
export RENDEZVOUS_SMOKE_REGION='<credential region>'
|
||||||
|
export RENDEZVOUS_SMOKE_PROTOCOL_VERSION='<enabled protocol version>'
|
||||||
|
./scripts/smoke-deployment.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Those four scope values must match both the short-lived credential and an
|
||||||
|
enabled server policy. The defaults (`space-game`, `smoke`, `local`, protocol
|
||||||
|
`1`) are only for the checked-in local Compose profile.
|
||||||
|
|
||||||
|
The smoke fails unless both health endpoints and the complete authenticated UDP
|
||||||
|
mediation/direct-traffic flow succeed. It does not prove every consumer NAT;
|
||||||
|
run the topology harness and representative external-network tests as well.
|
||||||
|
|
||||||
|
## Restart, upgrade, rollback, and backup
|
||||||
|
|
||||||
|
Rendezvous has no durable runtime database. Restarting intentionally loses all
|
||||||
|
listings, observed endpoints, attempts, replay markers, and runtime-only
|
||||||
|
revocations. Hosts must treat registration as a renewable lease and re-register
|
||||||
|
after service recovery. Clients must re-browse and start a new bounded attempt.
|
||||||
|
|
||||||
|
Back up only reviewed configuration, policy, secret references, key material and
|
||||||
|
its custody/lifecycle records, deployment manifests, and image digest. Never
|
||||||
|
claim a backup contains live sessions or endpoints. Restore keys only through the
|
||||||
|
secret system, not into the image or repository.
|
||||||
|
|
||||||
|
For an upgrade:
|
||||||
|
|
||||||
|
1. Build and test the new pinned digest; validate configuration without starting
|
||||||
|
a second active instance.
|
||||||
|
2. Drain and stop the current process, verify both sockets are released, then
|
||||||
|
start the replacement on the same public endpoints.
|
||||||
|
3. Require live/readiness and HTTP+UDP smoke success; monitor host
|
||||||
|
re-registration, error rate, and direct-connect outcomes.
|
||||||
|
|
||||||
|
For rollback, repeat the same stop-before-start sequence with the previously
|
||||||
|
recorded image digest and compatible configuration/key set. Never run old and
|
||||||
|
new versions concurrently to avoid split ephemeral state. If a wire-incompatible
|
||||||
|
change ever becomes necessary, use a new API/protocol version rather than a
|
||||||
|
rolling two-version replica set.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"evidenceVersion": "v2",
|
||||||
|
"generatedAt": "2026-07-16T14:10:53.6981858+00:00",
|
||||||
|
"profile": "candidate",
|
||||||
|
"runtime": {
|
||||||
|
"framework": ".NET 10.0.9",
|
||||||
|
"operatingSystem": "CachyOS",
|
||||||
|
"kernel": "Unix 7.1.3.2",
|
||||||
|
"architecture": "X64",
|
||||||
|
"cpuModel": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||||
|
"processorCount": 2,
|
||||||
|
"cpuAffinity": "0,1",
|
||||||
|
"cpuQuota": "not-enforced",
|
||||||
|
"memoryLimit": "not-enforced",
|
||||||
|
"garbageCollector": "workstation",
|
||||||
|
"commitSha": "cf14836d48b0b4aaa67f99433f4fba3585bcd2bb",
|
||||||
|
"treeState": "clean",
|
||||||
|
"command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh",
|
||||||
|
"imageDigest": "not-containerized",
|
||||||
|
"workloadSeed": "fixed-sequences-random-identifiers",
|
||||||
|
"capacityPhaseAverageCpuPercent": 56.37724115383554,
|
||||||
|
"peakWorkingSetBytes": 169705472,
|
||||||
|
"managedBytesAfterCleanup": 35615200
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"visibleListings": 25000,
|
||||||
|
"activeJoinAttempts": 10000,
|
||||||
|
"coreControlOperationsPerSecond": 200,
|
||||||
|
"coreMediationOperationsPerSecond": 2000,
|
||||||
|
"coreControlP95Milliseconds": 200,
|
||||||
|
"coreMediationP95Milliseconds": 100,
|
||||||
|
"maximumAverageCpuPercent": 70,
|
||||||
|
"maximumWorkingSetBytes": 1610612736,
|
||||||
|
"soakCycles": 1000,
|
||||||
|
"soakDurationSeconds": 300
|
||||||
|
},
|
||||||
|
"measurements": [
|
||||||
|
{
|
||||||
|
"operation": "registration-and-presence",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.003,
|
||||||
|
"p95Milliseconds": 0.0046,
|
||||||
|
"p99Milliseconds": 0.0054,
|
||||||
|
"operationsPerSecond": 282453.96000451926,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "lease-renewal",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0004,
|
||||||
|
"p95Milliseconds": 0.0009,
|
||||||
|
"p99Milliseconds": 0.0021,
|
||||||
|
"operationsPerSecond": 968992.2480620155,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "visible-session-browse",
|
||||||
|
"samples": 250,
|
||||||
|
"p50Milliseconds": 0.9046,
|
||||||
|
"p95Milliseconds": 3.3704,
|
||||||
|
"p99Milliseconds": 3.9471,
|
||||||
|
"operationsPerSecond": 695.5799787597697,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "join-attempt-issuance",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0029,
|
||||||
|
"p95Milliseconds": 0.0045,
|
||||||
|
"p99Milliseconds": 0.0055,
|
||||||
|
"operationsPerSecond": 296428.042092782,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "simultaneous-punch-pairing",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0043,
|
||||||
|
"p95Milliseconds": 0.0073,
|
||||||
|
"p99Milliseconds": 0.0115,
|
||||||
|
"operationsPerSecond": 109212.03516627532,
|
||||||
|
"minimumOperationsPerSecond": 2000,
|
||||||
|
"budgetMilliseconds": 100,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "principal-revocation",
|
||||||
|
"samples": 50,
|
||||||
|
"p50Milliseconds": 0.518,
|
||||||
|
"p95Milliseconds": 0.7049,
|
||||||
|
"p99Milliseconds": 11.8557,
|
||||||
|
"operationsPerSecond": 1320.1773262184577,
|
||||||
|
"minimumOperationsPerSecond": 50,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "telemetry-recording",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0001,
|
||||||
|
"p95Milliseconds": 0.0001,
|
||||||
|
"p99Milliseconds": 0.0001,
|
||||||
|
"operationsPerSecond": 1438641.9220256077,
|
||||||
|
"minimumOperationsPerSecond": 10000,
|
||||||
|
"budgetMilliseconds": 1,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "coincident-listing-attempt-expiry",
|
||||||
|
"samples": 1,
|
||||||
|
"p50Milliseconds": 29.6882,
|
||||||
|
"p95Milliseconds": 29.6882,
|
||||||
|
"p99Milliseconds": 29.6882,
|
||||||
|
"operationsPerSecond": 33.682962483916384,
|
||||||
|
"minimumOperationsPerSecond": 0,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": {
|
||||||
|
"peakListings": 25000,
|
||||||
|
"peakAttempts": 10000,
|
||||||
|
"peakReplayMarkers": 0,
|
||||||
|
"finalListings": 0,
|
||||||
|
"finalAttempts": 0,
|
||||||
|
"finalReplayMarkers": 0,
|
||||||
|
"expiryChurn": 94906,
|
||||||
|
"maintenanceSweeps": 36307,
|
||||||
|
"soakCyclesCompleted": 75126848,
|
||||||
|
"soakDurationSeconds": 300.0000015,
|
||||||
|
"soakPeakScheduledExpiryEntries": 7,
|
||||||
|
"soakManagedGrowthBytes": -257288,
|
||||||
|
"soakHandleGrowth": 2,
|
||||||
|
"restartStartedEmpty": true,
|
||||||
|
"overloadWasTyped": true,
|
||||||
|
"recoverySucceeded": true
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Diagnostic TestClient integration guide
|
||||||
|
|
||||||
|
Tracking: #25
|
||||||
|
|
||||||
|
`FinalFactory.Rendezvous.TestClient` is the smallest supported public-SDK consumer.
|
||||||
|
It exists for integration development, CI smoke checks, deployment verification,
|
||||||
|
and operator diagnosis. It is intentionally not a production game client, game
|
||||||
|
server, matchmaking UI, or relay.
|
||||||
|
|
||||||
|
The automated scenario matrix, privileged Linux namespace run, and topology
|
||||||
|
limitations are documented in the [deterministic topology harness](topology-harness.md).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Start a configured Rendezvous service and note both its HTTP base URL and UDP
|
||||||
|
mediator endpoint. The host needs a tenant-scoped publisher credential from the
|
||||||
|
deployment secret boundary. Put it in an environment variable and pass only that
|
||||||
|
variable's name when the default is unsuitable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<deployment-supplied value>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Never put the credential in a command argument, URL, checked-in configuration,
|
||||||
|
shell trace, or captured test fixture. The development server's signing material
|
||||||
|
is process-ephemeral; credentials from a prior development process are invalid.
|
||||||
|
|
||||||
|
## Manual three-terminal flow
|
||||||
|
|
||||||
|
Start the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||||
|
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
||||||
|
--game space-game --environment development --region local --protocol 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Browse from another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||||
|
browse --service http://127.0.0.1:5000/ \
|
||||||
|
--game space-game --environment development --region local --protocol 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Join from a third terminal. Omit `--listing` for an interactive choice:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
|
||||||
|
join --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
|
||||||
|
--game space-game --environment development --region local --protocol 1 \
|
||||||
|
--listing 00000000-0000-0000-0000-000000000000
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the sample UUID with the public listing ID printed by host or browse.
|
||||||
|
Host and join each create one caller-owned LiteNetLib manager. That same socket
|
||||||
|
sends presence/punch traffic, establishes the authenticated direct connection,
|
||||||
|
and carries the ping/echo/ack/completion payload. The final completion confirms
|
||||||
|
that the host received the reliable acknowledgement; none of this traffic passes through the HTTP
|
||||||
|
service or UDP mediator.
|
||||||
|
|
||||||
|
## CI and deployment smoke flow
|
||||||
|
|
||||||
|
Use `--script --json`, set `--listing` when deterministic selection matters, and
|
||||||
|
check the documented process exit code. `--timeout-seconds` bounds each startup,
|
||||||
|
traversal, or direct-traffic stage; a script host also uses it as its total runtime
|
||||||
|
unless `--run-seconds` is explicit. A host can add `--exit-after-echo` so it
|
||||||
|
terminates after the joining peer acknowledges direct traffic and receives the
|
||||||
|
host's completion confirmation. Every wait is
|
||||||
|
bounded by coordinator state and `--timeout-seconds`; no orchestration should use
|
||||||
|
an unbounded sleep.
|
||||||
|
|
||||||
|
The normal test suite contains a real process gate that starts the built Server,
|
||||||
|
host TestClient, and join TestClient, waits for readiness and versioned events,
|
||||||
|
and verifies direct traffic, cleanup, JSON shape, and secret canaries. Process
|
||||||
|
trees are force-terminated in the test cleanup path if normal shutdown fails.
|
||||||
|
|
||||||
|
Useful success events are:
|
||||||
|
|
||||||
|
- `host.registered`, `host.ready`, `host.direct-traffic`, and `host.deregistered`;
|
||||||
|
- `browse.completed` and `browse.session`; and
|
||||||
|
- `join.connected`, `join.direct-traffic`, and `join.outcome-report`.
|
||||||
|
|
||||||
|
Failure events preserve stable typed phases and outcomes. When a terminal outcome
|
||||||
|
contains a configured dedicated endpoint, `join.fallback` reports `available`
|
||||||
|
with endpoint type `dedicated`; no raw address is printed and no fallback is
|
||||||
|
started implicitly.
|
||||||
|
|
||||||
|
## What the proof does and does not establish
|
||||||
|
|
||||||
|
The deterministic loopback test proves the complete service/host/client protocol,
|
||||||
|
ticket admission, and peer-to-peer payload path. Loopback is not evidence that all
|
||||||
|
consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths,
|
||||||
|
or platform policies permit hole punching. Same-LAN, separated observed endpoints,
|
||||||
|
network namespaces/containers, mediator restart, and adverse topology coverage
|
||||||
|
belong to the topology harness tracked by #14. Production rollout still requires
|
||||||
|
tests from representative networks and a game-owned fallback policy.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Deterministic topology harness
|
||||||
|
|
||||||
|
Issue #14 is verified at three layers. The layers are deliberately separate so
|
||||||
|
the always-on gate remains deterministic while privileged CI workers can add a
|
||||||
|
stronger operating-system topology without overstating what local emulation
|
||||||
|
proves about the public Internet.
|
||||||
|
|
||||||
|
## Always-on public-process gate
|
||||||
|
|
||||||
|
`TestClientProcessIntegrationTests` launches the built server and the same
|
||||||
|
`FinalFactory.Rendezvous.TestClient` executable shipped to operators. Every
|
||||||
|
child process uses `--script --json`, dynamic HTTP and UDP ports, bounded
|
||||||
|
state-driven waits, and enforced process-tree cleanup.
|
||||||
|
|
||||||
|
The suite proves:
|
||||||
|
|
||||||
|
| Scenario | Required observation |
|
||||||
|
| --- | --- |
|
||||||
|
| Three-party happy path | register, presence-ready, browse, authorize, punch, authenticated LiteNetLib connection, direct ping/echo/ack/completion traffic, outcome report, disconnect, deregister |
|
||||||
|
| Same-LAN candidate | the connected peer is reported as `loopback` or `private`, never inferred merely from an introduction callback |
|
||||||
|
| Empty and missing selection | browse exits `11`; exact missing lookup exits `10` |
|
||||||
|
| Wrong tenant/protocol | no listing is returned for an incompatible protocol; exact joins with either mismatch fail before `join.punch` |
|
||||||
|
| Traversal timeout | an unreachable mediator produces typed `PunchTimedOut`, exits `12`, advertises the configured dedicated fallback, and never connects to it |
|
||||||
|
| Caller cancellation | POSIX `SIGINT` exits `130`, deregisters the listing, and removes it from public lookup |
|
||||||
|
| Abrupt host loss | the listing disappears after the presence window and before its lease expires; public exact lookup intentionally reports `NotFound` |
|
||||||
|
| Bounded host without a peer | exits `13` and still deregisters |
|
||||||
|
|
||||||
|
Captured output is parsed as the stable JSON v1 event schema. Publisher
|
||||||
|
credentials and signing-key material are checked against all captured output.
|
||||||
|
The direct traffic payload is handled only by the caller-owned host and client
|
||||||
|
LiteNetLib managers; the HTTP service and mediator do not implement or observe
|
||||||
|
the echo protocol.
|
||||||
|
|
||||||
|
Run the always-on scenarios with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test Rendezvous.slnx --configuration Release --no-build \
|
||||||
|
--filter FullyQualifiedName~TestClientProcessIntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deterministic protocol and adverse-state gate
|
||||||
|
|
||||||
|
The following real service-boundary tests cover conditions that a public CLI
|
||||||
|
cannot safely manufacture by accepting raw capabilities or tickets:
|
||||||
|
|
||||||
|
| Scenario | Test evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| Same-NAT private candidates | `NatMediationProcessorTests.MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates` |
|
||||||
|
| Separate observed endpoints | `NatMediationProcessorTests.DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints` |
|
||||||
|
| One-time introduction and replay | `InMemoryEphemeralRendezvousStoreTests.AttemptCapabilitiesAndIntroductionAreOneTime` |
|
||||||
|
| Direct ticket replay | `RendezvousCoordinatorIntegrationTests.CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay` |
|
||||||
|
| Wrong tenant/protocol and stale presence | `InMemoryEphemeralRendezvousStoreTests.JoinRequiresExactScopeProtocolAndFreshHostPresence` |
|
||||||
|
| Cancellation and late callbacks | `RendezvousCoordinatorBehaviorTests.CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt` |
|
||||||
|
| Mediator restart | both cases of `UdpMediatorServiceTests.NativeLiteNetLibRequestsIntroduceTheAuthorizedPair`; the restarted case rebinds the same UDP port and completes a native LiteNetLib introduction |
|
||||||
|
|
||||||
|
These tests use fake monotonic clocks or state predicates where expiry and race
|
||||||
|
ordering matter. They do not use fixed sleeps as proof of state.
|
||||||
|
|
||||||
|
## Privileged Linux namespace gate
|
||||||
|
|
||||||
|
When a Linux CI worker can create network namespaces, the workflow sets
|
||||||
|
`RENDEZVOUS_RUN_NETNS_TESTS=1` and reruns
|
||||||
|
`PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints`.
|
||||||
|
The test creates a temporary WAN bridge, an isolated service namespace, two NAT
|
||||||
|
router namespaces, and isolated host/client LAN namespaces. Each NAT has its own
|
||||||
|
inside subnet and WAN address. Linux forwarding plus per-router MASQUERADE rules
|
||||||
|
force the service to observe separate translated endpoints; the public TestClient
|
||||||
|
processes must then complete authenticated direct traffic through those mappings
|
||||||
|
using the public candidate. Namespaces, rules, veth pairs, bridge, processes, and
|
||||||
|
sockets are removed in bounded async-disposal paths. A cleanup failure fails the
|
||||||
|
test.
|
||||||
|
|
||||||
|
If `ip netns add`/`iptables` is unavailable or the worker lacks `CAP_NET_ADMIN`,
|
||||||
|
CI records the limitation and keeps the always-on loopback suite as the required gate.
|
||||||
|
To request the privileged run explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RENDEZVOUS_RUN_NETNS_TESTS=1 dotnet test Rendezvous.slnx \
|
||||||
|
--configuration Release --no-build \
|
||||||
|
--filter FullyQualifiedName~PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this does not prove
|
||||||
|
|
||||||
|
Loopback, MASQUERADE, and namespace routing cannot reproduce every consumer router,
|
||||||
|
carrier-grade NAT, firewall, IPv6 transition mechanism, symmetric NAT mapping,
|
||||||
|
or real-world packet-loss pattern. The separate-observed-endpoint processor
|
||||||
|
test proves that untrusted private claims are excluded and public candidates are
|
||||||
|
selected; it is not presented as universal Internet traversal proof. Real
|
||||||
|
network canaries and measured production readiness remain the scope of issue
|
||||||
|
#23.
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Capacity, resilience, and availability gate
|
||||||
|
|
||||||
|
Tracking: #18
|
||||||
|
|
||||||
|
This gate turns the v1 budgets in ADR 0003 into a repeatable release decision.
|
||||||
|
It does not turn Rendezvous into a horizontally scalable service: v1 remains one
|
||||||
|
active process with bounded in-memory state. A second process may be a cold
|
||||||
|
standby, but it must not accept traffic until the first process has stopped and
|
||||||
|
released the public HTTP and UDP endpoints.
|
||||||
|
|
||||||
|
## Launch envelope and approved core-state profile
|
||||||
|
|
||||||
|
The approved core-state profile is one Linux process limited to 2 vCPU and
|
||||||
|
2 GiB RAM. Public HTTP/UDP numbers are launch objectives that require the #23
|
||||||
|
real-network canary before they become a supported service claim:
|
||||||
|
|
||||||
|
| Dimension | Value | Evidence status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Visible listings | 25,000 | Enforced and measured here |
|
||||||
|
| Active join attempts | 10,000 | Enforced and measured here |
|
||||||
|
| Core control path | 200 operations/second; p95 at most 200 ms | Measured here |
|
||||||
|
| Core mediation path | 2,000 pairings/second; p95 at most 100 ms | Measured here |
|
||||||
|
| Sustained HTTP demand | 200 requests/second | #23 launch objective; not yet a supported claim |
|
||||||
|
| Sustained UDP demand | 2,000 datagrams/second | #23 launch objective; not yet a supported claim |
|
||||||
|
| Public HTTP/UDP latency | p95 at most 200 ms / 100 ms | #23 launch objective; not yet a supported claim |
|
||||||
|
| Capacity-phase average CPU / peak working memory | below 70% / below 1.5 GiB | Measured for the core candidate |
|
||||||
|
| Valid in-profile monthly availability | 99.5%, excluding announced maintenance | Operational objective |
|
||||||
|
| Process-ready RTO / host-visible recovery | 15 seconds / 90 seconds | 15 seconds automated; 90-second deployment drill required |
|
||||||
|
|
||||||
|
The proposed public-network mix is 20% registration/update, 30% lease-critical
|
||||||
|
renew/delete, 30% browse, and 20% join authorization for HTTP. The UDP mix is
|
||||||
|
60% authenticated host-presence refresh, 30% attempt contributions, and 10%
|
||||||
|
invalid or duplicate traffic that must be dropped early. A deployment may use a
|
||||||
|
lower per-game profile, but must not claim a higher one without new versioned
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
The capacity harness fills the complete state ceilings, then measures
|
||||||
|
registration plus presence, renewal, a 100-item compatible browse, join
|
||||||
|
issuance, simultaneous two-peer pairing, principal revocation, and telemetry.
|
||||||
|
It applies 200/100 ms guardrails and minimum 200 control / 2,000 mediation
|
||||||
|
operations per second to the core hot path. Those measurements deliberately
|
||||||
|
exclude Kestrel, LiteNetLib, TLS, JSON, socket scheduling, and the documented
|
||||||
|
mixed traffic shape. The #23 real-network canary must exercise those layers,
|
||||||
|
rate-shape the mix, record errors and shedding, and meet the public objectives
|
||||||
|
before launch; a core result is not a public-network latency or throughput claim.
|
||||||
|
|
||||||
|
## Reproduce the evidence
|
||||||
|
|
||||||
|
Every push runs the quick profile and the selected fault matrix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-capacity-gate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the production candidate on an otherwise idle Linux host and restrict the
|
||||||
|
runtime to two logical CPUs. The default candidate includes a five-minute,
|
||||||
|
high-intensity expiry soak; use 3,600 seconds for a release-candidate endurance
|
||||||
|
run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_CAPACITY_PROFILE=candidate
|
||||||
|
export RENDEZVOUS_CAPACITY_CPUSET=0,1
|
||||||
|
export RENDEZVOUS_CAPACITY_OUTPUT="$PWD/artifacts/capacity/candidate.json"
|
||||||
|
./scripts/run-capacity-gate.sh
|
||||||
|
|
||||||
|
# Release-candidate endurance override:
|
||||||
|
dotnet run --project tests/FinalFactory.Rendezvous.Capacity \
|
||||||
|
--configuration Release --no-build -- \
|
||||||
|
--profile candidate --soak-seconds 3600 \
|
||||||
|
--output artifacts/capacity/candidate-endurance.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The machine must have at least 2 GiB available to the process. For formal
|
||||||
|
deployment evidence, run inside the same cgroup/container shape as production.
|
||||||
|
The v2 JSON embeds the commit and tree state, command, image context, CPU model,
|
||||||
|
kernel, affinity, cgroup quota/limit, collector mode, and workload seed. Supply
|
||||||
|
`RENDEZVOUS_EVIDENCE_IMAGE_DIGEST` when running a release image. Do not compare
|
||||||
|
results collected under a debugger,
|
||||||
|
concurrent build, thermal throttling, or oversubscribed CI host.
|
||||||
|
|
||||||
|
The checked-in baseline is
|
||||||
|
[`candidate-2cpu.json`](../evidence/capacity/v2/candidate-2cpu.json). It was
|
||||||
|
produced on .NET 10.0.9/Linux x64 with CPU affinity restricted to two logical
|
||||||
|
CPUs. It filled 25,000 listings and 10,000 attempts, peaked at about 162 MiB,
|
||||||
|
and cleared all active/retained state. The five-minute baseline supersedes any
|
||||||
|
earlier local probe when its timestamp and target duration differ.
|
||||||
|
|
||||||
|
## Soak and bounded-state interpretation
|
||||||
|
|
||||||
|
Each soak cycle creates a listing, repeatedly renews its lease and refreshes
|
||||||
|
presence, creates a join attempt, replay marker, and retained outcome, checks
|
||||||
|
that scheduled expiry entries remain proportional to live keys, then advances
|
||||||
|
the injected monotonic clock beyond all
|
||||||
|
deadlines, and verifies that listings, attempts, replay, idempotency, and outcome
|
||||||
|
state return to zero. The candidate also measures managed-memory and process
|
||||||
|
handle deltas after full collection. Failure is any retained state, more than
|
||||||
|
64 MiB retained managed memory, more than eight retained handles, a working set
|
||||||
|
above 1.5 GiB, an untyped capacity result, or failure to admit work after expiry.
|
||||||
|
|
||||||
|
This accelerated soak intentionally executes far more state lifecycle/cleanup
|
||||||
|
events than wall-clock traffic would permit. It catches stale deadline-queue
|
||||||
|
entries, cache growth, replay/idempotency retention, and cleanup cost. Because
|
||||||
|
it does not open Kestrel/LiteNetLib connections, its process-handle delta is only
|
||||||
|
a harness guard and is not evidence of transport stability by itself. The
|
||||||
|
selected production-process gate adds a ten-second real HTTP/UDP transport soak,
|
||||||
|
samples child-process handles and RSS, asserts bounded growth, then verifies a
|
||||||
|
clean SIGTERM and socket release. #23 must extend that into the full rate-shaped
|
||||||
|
multi-client canary while sampling queues, managed memory, and state
|
||||||
|
cardinalities. A one-hour core override remains required before tagging a
|
||||||
|
production release.
|
||||||
|
|
||||||
|
## Fault and recovery matrix
|
||||||
|
|
||||||
|
`run-capacity-gate.sh` runs these deterministic production paths before the
|
||||||
|
numeric profile:
|
||||||
|
|
||||||
|
| Fault | Required result |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTP/UDP overload and tracker exhaustion | Typed HTTP `429`/`CapacityExceeded`, silent UDP drop, bounded tracker keys, recovery after the window |
|
||||||
|
| Optional traffic saturation | Lease-critical renew/update/delete capacity remains available |
|
||||||
|
| Store/dependency unavailable | Readiness fails; new authorization returns typed `ServiceUnavailable`; liveness remains independent |
|
||||||
|
| Graceful drain/SIGTERM | New work returns `Draining`; existing pairing may finish; process exits 0 and releases TCP/UDP before the deadline |
|
||||||
|
| Hard restart | In-flight state is lost; SDK reports typed `ServiceUnavailable`; a host re-registers, rebinds presence, and becomes the only browser-visible replacement |
|
||||||
|
| UDP listener bind/restart | Readiness stays false without the required listener; rebinding the advertised port restores native LiteNetLib pairing |
|
||||||
|
| Wall-clock jump/skew | Monotonic lease/attempt authority is neither shortened nor extended; credential skew remains capped at 30 seconds |
|
||||||
|
| Signing-secret rotation | New key signs, overlap verifies, retired/revoked key rejects, missing material fails startup |
|
||||||
|
| Principal revocation | Listing, presence, attempts, and outcome paths are removed atomically within the latency budget |
|
||||||
|
|
||||||
|
No external database exists in v1, so “dependency/store failure” means the
|
||||||
|
process-local atomic store is marked unavailable or a required listener/key is
|
||||||
|
unready. The service fails closed rather than pretending a degraded writable
|
||||||
|
mode exists.
|
||||||
|
|
||||||
|
## Bandwidth and amplification
|
||||||
|
|
||||||
|
- Accepted application datagrams are at most 1,200 bytes.
|
||||||
|
- Malformed, oversized, unauthenticated, stale, replayed, wrong-role, and
|
||||||
|
rate-limited traffic receives zero response bytes.
|
||||||
|
- A completing authenticated contribution produces at most one introduction to
|
||||||
|
each observed peer, and the combined response is at most 2.0 times that
|
||||||
|
contribution's bytes.
|
||||||
|
- The frozen-envelope and native LiteNetLib socket tests measure this on the real
|
||||||
|
UDP listener; the hostile corpus and allocation gate exercise 10,000+ inputs
|
||||||
|
without input-sized logs, tasks, or queues.
|
||||||
|
|
||||||
|
Bandwidth planning must therefore reserve ingress for the configured 2,000
|
||||||
|
datagrams/second plus edge overhead and egress for a worst-case verified 2.0
|
||||||
|
amplification. Actual successful pairs normally use two contributions and two
|
||||||
|
introductions; normal gameplay leaves Rendezvous entirely.
|
||||||
|
|
||||||
|
## Availability decision
|
||||||
|
|
||||||
|
Single-active remains the v1 topology. The measured core profile proves bounded
|
||||||
|
state and substantial core-path headroom, while public launch capacity remains
|
||||||
|
conditional on #23. The service has a bounded stop-before-start restart path.
|
||||||
|
Its failure domain is deliberately
|
||||||
|
one process/node/public UDP endpoint: node, kernel, host network, DNS/TLS edge,
|
||||||
|
secret configuration, or operator error can remove all readiness until the cold
|
||||||
|
replacement owns the same source-preserving endpoint.
|
||||||
|
|
||||||
|
The 99.5% objective permits about 216 minutes of unannounced downtime in a
|
||||||
|
30-day month. Operations must target process readiness within 15 seconds and
|
||||||
|
host-visible re-registration within 90 seconds, page when no ready instance
|
||||||
|
exists, and include detection plus recovery in the monthly budget. The current
|
||||||
|
in-process test validates typed downtime, same-port HTTP restart, fresh
|
||||||
|
registration, presence rebinding, and browser visibility in under five seconds;
|
||||||
|
the production-process test separately validates graceful termination, TCP/UDP
|
||||||
|
release, replacement startup on the same endpoints, UDP readiness, and the
|
||||||
|
15-second process-ready RTO. Cold-standby activation policy and the 90-second
|
||||||
|
operator-to-host recovery objective still require a deployment drill before
|
||||||
|
release. Rollout and rollback use the
|
||||||
|
deployment runbook's drain, stop, socket-release, start, smoke sequence; never
|
||||||
|
overlap old and new active processes.
|
||||||
|
|
||||||
|
Bring shared TTL/CAS state and deterministic mediator routing forward before
|
||||||
|
enabling two active instances if any of these occurs:
|
||||||
|
|
||||||
|
- one node cannot sustain 150% of the measured 30-day peak while meeting SLOs;
|
||||||
|
- CPU stays above 70%, memory above 75%, attempt depth above 70%, or limiter
|
||||||
|
drops/latency remain elevated after abusive traffic is excluded;
|
||||||
|
- the availability target rises above 99.5% or planned maintenance must preserve
|
||||||
|
listings; or
|
||||||
|
- one region requires multiple simultaneously active mediator endpoints.
|
||||||
|
|
||||||
|
Rendezvous makes no multi-instance claim today, so a two-node atomic-pairing
|
||||||
|
test is intentionally not applicable. It becomes a hard release gate with the
|
||||||
|
shared-state/routing implementation; until then `SingleActiveInstance=false`
|
||||||
|
fails production startup. Multi-region and relay remain separate evidence-driven
|
||||||
|
decisions.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Observability and operator runbook
|
||||||
|
|
||||||
|
This runbook defines the production signals and privileged controls for the
|
||||||
|
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
|
||||||
|
from the `FinalFactory.Rendezvous` meter and distributed-tracing activities from
|
||||||
|
`FinalFactory.Rendezvous.Server`. Connect those sources to the deployment's
|
||||||
|
OpenTelemetry or equivalent collector. Do not add identifiers to metric labels.
|
||||||
|
|
||||||
|
## Health and readiness
|
||||||
|
|
||||||
|
- `GET /health/live` proves that the HTTP process can answer. It deliberately
|
||||||
|
remains independent of provisioning, the state store, drain state, and optional
|
||||||
|
listeners so an orchestrator does not restart a recoverable dependency failure.
|
||||||
|
- `GET /health/ready` returns success only after the HTTP path is answering, the
|
||||||
|
required IPv4 UDP socket is bound, any configured IPv6 UDP socket is bound,
|
||||||
|
provisioning loaded successfully, the store is available, and drain has not
|
||||||
|
started. A failed check returns `503` and removes the instance from new work.
|
||||||
|
- A graceful drain immediately makes readiness fail while liveness remains healthy.
|
||||||
|
Existing work may complete until the bounded store drain deadline.
|
||||||
|
|
||||||
|
## Metrics and traces
|
||||||
|
|
||||||
|
| Instrument | Purpose | Bounded dimensions |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `rendezvous.http.requests` / `rendezvous.http.duration` | HTTP volume and latency | operation, status code |
|
||||||
|
| `rendezvous.udp.results` / `rendezvous.udp.duration` | UDP mediation volume and processing latency | frozen/litenet operation, result |
|
||||||
|
| `rendezvous.limiter.drops` | Requests shed by admission controls | transport, fixed partition class |
|
||||||
|
| `rendezvous.operator.authentication` | Accepted, forbidden, and rejected operator authentication | result |
|
||||||
|
| `rendezvous.audit.events` | Privileged action outcomes | fixed action, result |
|
||||||
|
| `rendezvous.connection.outcomes` | Client-reported direct-connect outcomes | normalized outcome, elapsed bucket |
|
||||||
|
| `rendezvous.pairing.latency` | Time from attempt creation to successful peer introduction | none |
|
||||||
|
| `rendezvous.queue.depth` | Active join-attempt queue depth | none |
|
||||||
|
| `rendezvous.store.active_listings` / `active_leases` / `active_attempts` / `replay_markers` | Current ephemeral load | none |
|
||||||
|
| `rendezvous.store.expiry_churn` | Cumulative natural expiry activity | none |
|
||||||
|
| `rendezvous.store.available` | Store health (`1` available, `0` unavailable) | none |
|
||||||
|
|
||||||
|
HTTP responses include `X-Rendezvous-Correlation-ID`. It is a generated trace ID
|
||||||
|
or random value, never a caller-supplied session or player identifier. UDP and
|
||||||
|
HTTP activities contain operation-level data only. Logs and traces must not add
|
||||||
|
tokens, capabilities, session/listing IDs, player subjects, metadata, raw IP
|
||||||
|
addresses, or endpoint values.
|
||||||
|
|
||||||
|
Recommended dashboard panels are request rate and p50/p95/p99 latency by fixed
|
||||||
|
operation, UDP result ratio, direct connection success ratio, pairing latency,
|
||||||
|
active listings/attempts, expiry churn, limiter drops, store availability,
|
||||||
|
operator authentication results, audit action results, and signing-key windows.
|
||||||
|
|
||||||
|
## Alerts
|
||||||
|
|
||||||
|
Tune thresholds from the normal production baseline, then keep these conditions
|
||||||
|
as distinct actionable alerts:
|
||||||
|
|
||||||
|
- **Signing key expiry:** page when any required signing key has less than seven
|
||||||
|
days before `signUntil`; escalate at 24 hours. Confirm a replacement is signing
|
||||||
|
and the previous key remains verify-only for the maximum credential lifetime.
|
||||||
|
- **Authentication spike:** warn when rejected or forbidden operator authentication
|
||||||
|
exceeds five attempts in five minutes. Treat unexpected publisher-authentication
|
||||||
|
growth as a possible credential or integration incident.
|
||||||
|
- **Direct success regression:** warn when the connected outcome ratio falls more
|
||||||
|
than 20% below its seven-day same-region baseline for 15 minutes, with a minimum
|
||||||
|
sample floor. Break down only by bounded outcome and time bucket.
|
||||||
|
- **Saturation:** warn when queue depth remains above 70% of the configured attempt
|
||||||
|
limit, limiter drops are sustained, or p95 latency exceeds the service objective;
|
||||||
|
page at 90% or when lease-critical traffic is shed.
|
||||||
|
- **Store degradation:** page immediately when `rendezvous.store.available` is zero
|
||||||
|
or readiness fails for the store. Rising expiry churn without corresponding new
|
||||||
|
work is a warning for stalled clients or clock/configuration mistakes.
|
||||||
|
- **Listener/config readiness:** page when no ready instances remain. Investigate
|
||||||
|
UDP bind failures, a configured-but-unbound IPv6 listener, provisioning errors,
|
||||||
|
and unintended drain state separately.
|
||||||
|
|
||||||
|
## Operator authentication and controls
|
||||||
|
|
||||||
|
Operator credentials use a signing key configured with `CredentialKinds:
|
||||||
|
["Operator"]`. Operator keys cannot be scoped to a game/environment or used for
|
||||||
|
publisher credentials. Mint short-lived operator credentials through the trusted
|
||||||
|
provisioning process, outside the public Rendezvous HTTP service, and grant only
|
||||||
|
the required permission. Never place credentials in command history, URLs, logs,
|
||||||
|
or support tickets.
|
||||||
|
|
||||||
|
The application also enforces a default-deny source boundary. Configure at most
|
||||||
|
32 exact operator source IPs in
|
||||||
|
`Rendezvous:AbuseProtection:OperatorAllowedAddresses`; an empty list disables all
|
||||||
|
operator HTTP access. Development permits loopback only. Production must place
|
||||||
|
`/v1/operator/*` behind a private management listener or reverse-proxy ACL, list
|
||||||
|
only the resulting trusted management source addresses, and block that path on
|
||||||
|
the public edge. If forwarded headers are enabled, keep the existing exact-proxy,
|
||||||
|
single-hop trust policy and allowlist the post-forwarding operator source. Verify
|
||||||
|
from both an allowed management host and a denied public host before deployment.
|
||||||
|
Denied sources are charged to the bounded general HTTP partition before credential
|
||||||
|
or request-body processing, then receive `404`; sustained denied traffic receives
|
||||||
|
the same typed `429` overload response as other public traffic.
|
||||||
|
|
||||||
|
Operator traffic has a dedicated, bounded rate/concurrency partition and critical
|
||||||
|
tracker-key reserve. Public browse/join saturation therefore cannot consume the
|
||||||
|
operator control budget, while compromised management sources remain rate-limited.
|
||||||
|
|
||||||
|
The OpenAPI document defines the separate `OperatorBearer` scheme. All endpoints
|
||||||
|
are under `/v1/operator`:
|
||||||
|
|
||||||
|
| Endpoint | Permission | Confirmation |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /status` | `ReadPolicy` | none; returns aggregates, tenant status, safe key status, and audit counts |
|
||||||
|
| `POST /listings/revoke` | `RevokePublisher` | repeat the exact listing ID in `confirmListingId` |
|
||||||
|
| `POST /principals/revoke` | `RevokePublisher` | repeat the exact subject and choose a 1-600 second revocation lifetime |
|
||||||
|
| `POST /keys/revoke` | `RotateKeys` | repeat the exact key ID; runtime revocation is immediate |
|
||||||
|
| `POST /drain` | `ManagePolicy` | send the exact value `DRAIN` |
|
||||||
|
|
||||||
|
Publisher credentials are rejected on this surface even if their subject resembles
|
||||||
|
an operator. Destructive responses do not echo identifiers. The status response
|
||||||
|
does not expose player identities, raw endpoints, session metadata, capabilities,
|
||||||
|
or tokens. Every authenticated operator action, rejected confirmation, and
|
||||||
|
permission denial is audited with actor and target fingerprints.
|
||||||
|
|
||||||
|
Key revocation is process-local in the current single-instance store. Apply the
|
||||||
|
same revocation to every instance, then replace configuration before restarting;
|
||||||
|
a restart reconstructs the configured key ring. Principal revocation is bounded
|
||||||
|
to ten minutes and removes that principal's active listings and attempts. A
|
||||||
|
repeat action may extend an active revocation but never shortens it; wait for its
|
||||||
|
original deadline rather than treating a shorter repeat as an un-revoke. Use
|
||||||
|
listing revocation for one targeted session and drain before planned shutdown.
|
||||||
|
|
||||||
|
## Audit retention and incident handling
|
||||||
|
|
||||||
|
The in-process audit trail defaults to 10,000 entries and 30 days. It evicts the
|
||||||
|
oldest record at capacity and purges expired records on the next write. Configure
|
||||||
|
`Rendezvous:Audit:MaxEntries` and `RetentionDays` within their validated bounds.
|
||||||
|
Export the structured `AuditTrail` log events through the deployment's protected
|
||||||
|
logging pipeline when durable retention is required; the in-memory trail is not a
|
||||||
|
durable compliance archive. Those events include only timestamps, fixed action
|
||||||
|
fields, correlation IDs, and actor/target fingerprints.
|
||||||
|
|
||||||
|
Audit records retain timestamp, fixed action/result, target kind, correlation ID,
|
||||||
|
and 96-bit SHA-256 fingerprints of actor and target. Routine logs contain only the
|
||||||
|
fixed action/result/target kind and correlation ID. Restrict audit access to the
|
||||||
|
operator role, retain aggregates only as long as operationally necessary, and
|
||||||
|
delete raw exported audit data according to the 30-day policy unless an incident
|
||||||
|
hold is approved.
|
||||||
|
|
||||||
|
During an incident: confirm readiness and store health; capture aggregate graphs
|
||||||
|
and correlation IDs; revoke the narrowest listing, principal, or key; drain only
|
||||||
|
when isolation is required; record the action in the incident timeline; and verify
|
||||||
|
that direct success, limiter drops, and authentication rates return to baseline.
|
||||||
|
Do not copy player data, endpoints, or credentials into the incident record.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Hostile-input and overload protection
|
||||||
|
|
||||||
|
Tracking: #15
|
||||||
|
|
||||||
|
Rendezvous treats every public HTTP request and UDP datagram as hostile. The
|
||||||
|
server applies bounded fixed-window request budgets and concurrency ceilings in
|
||||||
|
two stages so malformed input is discarded before expensive work while valid
|
||||||
|
traffic is also isolated by its authenticated scope.
|
||||||
|
|
||||||
|
## Enforcement order
|
||||||
|
|
||||||
|
1. Kestrel and the HTTP abuse middleware cap request bodies at 16 KiB. A known
|
||||||
|
oversized body receives a typed `413` response before endpoint dispatch.
|
||||||
|
2. Every HTTP request consumes global, source-prefix, and operation budgets and
|
||||||
|
acquires the corresponding concurrency leases. IPv4 sources share a `/24`
|
||||||
|
budget and IPv6 sources share a `/56` budget; raw addresses are not retained.
|
||||||
|
Non-lease operations also consume a smaller optional-work budget, leaving a
|
||||||
|
configured global and source-prefix reserve for renew, update, and delete
|
||||||
|
operations during shedding.
|
||||||
|
Health probes use their own source-prefix budget so public API overload cannot
|
||||||
|
make a healthy instance fail its orchestrator probes, while health traffic is
|
||||||
|
still bounded.
|
||||||
|
Operator endpoints likewise use a separate bounded rate/concurrency partition
|
||||||
|
backed by the critical tracker reserve. They first require an exact source IP
|
||||||
|
from the default-deny `OperatorAllowedAddresses` policy, so public traffic
|
||||||
|
cannot spend the incident-response budget.
|
||||||
|
3. Once an endpoint has safely derived identities, it also acquires applicable
|
||||||
|
tenant, principal or capability, and listing/attempt budgets. Secret
|
||||||
|
capabilities are represented only by bounded SHA-256 fingerprints.
|
||||||
|
4. Every UDP envelope consumes global, source-prefix, and wire-operation
|
||||||
|
budgets before decoding. A structurally and cryptographically valid request
|
||||||
|
then consumes capability, role, and mediation-handle budgets before state
|
||||||
|
mutation or introduction.
|
||||||
|
5. HTTP overload returns the stable `RateLimited` error, status `429`, and a
|
||||||
|
bounded `Retry-After` value in both the header and response contract. UDP
|
||||||
|
overload and every invalid UDP input are silently dropped.
|
||||||
|
|
||||||
|
The same HTTP identity budget is computed whether or not a listing or attempt
|
||||||
|
exists. Rejection therefore does not disclose resource existence. Publisher
|
||||||
|
authentication also completes before any tenant/resource operation, while the
|
||||||
|
pre-authentication source budget prevents invalid credentials from bypassing
|
||||||
|
load shedding.
|
||||||
|
|
||||||
|
## Bounded state and recovery
|
||||||
|
|
||||||
|
`Rendezvous:AbuseProtection:MaxTrackedKeys` is a hard combined ceiling for rate
|
||||||
|
and active-concurrency keys. General HTTP and UDP traffic cannot consume the
|
||||||
|
configured `CriticalTrackedKeyReserve`; lease operations, health probes, and
|
||||||
|
allowlisted operator controls may
|
||||||
|
use that reserve but never exceed the hard ceiling. A request that would exceed
|
||||||
|
its applicable ceiling fails closed without adding state. Fixed-window rate keys
|
||||||
|
are cleared at the next window boundary; concurrency keys are removed as their
|
||||||
|
request leases finish. HTTP and UDP trackers have separate locks and cardinality
|
||||||
|
partitions, so a UDP flood cannot block HTTP admission on a shared lock or
|
||||||
|
consume HTTP key capacity. This gives
|
||||||
|
deterministic burst recovery and prevents an attacker from growing a permanent
|
||||||
|
high-cardinality address, credential, or resource table.
|
||||||
|
|
||||||
|
The complete default profile is checked into
|
||||||
|
`src/FinalFactory.Rendezvous.Server/appsettings.json`. Operators may lower or
|
||||||
|
tune limits for a measured deployment profile, but must preserve all dimensions
|
||||||
|
and leave the tracker ceiling above the maximum simultaneous key set. A rolling
|
||||||
|
deployment should use the same profile on every instance. These per-process
|
||||||
|
limits are a final service boundary; an edge proxy may add stricter distributed
|
||||||
|
limits but is not a substitute for them.
|
||||||
|
|
||||||
|
When an HTTP reverse proxy is used, every immediate proxy address must be
|
||||||
|
allowlisted in `Rendezvous:AbuseProtection:TrustedProxyAddresses` (or indexed
|
||||||
|
environment variables such as
|
||||||
|
`Rendezvous__AbuseProtection__TrustedProxyAddresses__0`). Only one forwarded
|
||||||
|
hop is accepted. With an empty allowlist, forwarded headers are ignored and the
|
||||||
|
direct TCP peer is the source. Never add a broad network range or accept
|
||||||
|
untrusted `X-Forwarded-For` input: that would let a caller choose its own rate
|
||||||
|
partition.
|
||||||
|
|
||||||
|
## Reflection, disclosure, and logging rules
|
||||||
|
|
||||||
|
- UDP sends nothing for malformed, oversized, unauthenticated, stale,
|
||||||
|
replayed, wrong-role, or rate-limited input.
|
||||||
|
- Introductions are emitted only after both role-scoped capabilities bind to
|
||||||
|
their observed gameplay-socket sources. HTTP never supplies a public
|
||||||
|
introduction target.
|
||||||
|
- Private candidates must be same-family private unicast addresses and are used
|
||||||
|
only for peers observed behind the same public address.
|
||||||
|
- Abuse keys, exceptions, and responses never include bearer credentials,
|
||||||
|
capabilities, tickets, raw endpoints, metadata values, or hostile markup.
|
||||||
|
- Endpoint and capability values are not used as metric labels or log fields.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The deterministic test corpora use the recorded seeds `0x152026`, `0x154A50`,
|
||||||
|
and `0x1557A7E`. They exercise 10,000 arbitrary UDP envelopes through the
|
||||||
|
production decoder, 5,000 arbitrary HTTP/credential parser inputs, and 1,000
|
||||||
|
mutated state transitions, including the oversized and configured-capacity
|
||||||
|
boundaries.
|
||||||
|
Focused tests cover IPv4 and IPv6 prefix
|
||||||
|
partitioning, tenant/principal/resource concurrency, tracker exhaustion,
|
||||||
|
window recovery, wire-operation isolation, a steady-state allocation ceiling,
|
||||||
|
typed `429`/`413` responses, secret fingerprint redaction, and silent
|
||||||
|
authenticated UDP shedding. The existing state, contract, HTTP, client,
|
||||||
|
and mediator suites continue to cover cross-tenant access, replay, role swaps,
|
||||||
|
credential rotation, bounded metadata, endpoint validation, and one-shot
|
||||||
|
amplification behavior.
|
||||||
@@ -11,7 +11,7 @@ backlog where the control is implemented and verified.
|
|||||||
| Per-game credentials and signing keys | Provisioned principals and versioned keys are scoped to game/environment; secrets come from a provider and never a public binary. (#5) | Cross-tenant authorization tests, rotation/overlap/revocation tests, and secret scans. |
|
| Per-game credentials and signing keys | Provisioned principals and versioned keys are scoped to game/environment; secrets come from a provider and never a public binary. (#5) | Cross-tenant authorization tests, rotation/overlap/revocation tests, and secret scans. |
|
||||||
| Short-lived, single-purpose tokens resistant to replay | Issuer fixes audience, tenant, attempt, role, issued/expiry times, nonce, and key ID; store atomically consumes nonce/ticket. (#4, #6, #10) | Golden vectors; expired, future, mutated, wrong-role, wrong-tenant, and concurrent replay tests. |
|
| Short-lived, single-purpose tokens resistant to replay | Issuer fixes audience, tenant, attempt, role, issued/expiry times, nonce, and key ID; store atomically consumes nonce/ticket. (#4, #6, #10) | Golden vectors; expired, future, mutated, wrong-role, wrong-tenant, and concurrent replay tests. |
|
||||||
| Strict payload, metadata, and token size limits | ADR 0003 ceilings are checked before allocation/deserialization and again at domain construction. (#4, #15) | Boundary/property tests, malformed corpus, and allocation-aware fuzzing. |
|
| Strict payload, metadata, and token size limits | ADR 0003 ceilings are checked before allocation/deserialization and again at domain construction. (#4, #15) | Boundary/property tests, malformed corpus, and allocation-aware fuzzing. |
|
||||||
| Registration, query, and introduction rate limits | Layered per-address, principal, tenant, and global token buckets with bounded queues and stable retry guidance. (#15) | Limit partition/isolation tests and overload/soak profiles. |
|
| Registration, query, and introduction rate limits | Layered fixed-window budgets and concurrency leases cover global, operation, IPv4 `/24` or IPv6 `/56`, tenant, principal/capability, and listing/attempt dimensions with a bounded key table and stable retry guidance. (#15) | Deterministic partition, concurrency, tracker-exhaustion, recovery, typed-overload, and silent-UDP-shedding tests. |
|
||||||
| Lease expiry removes abandoned servers | Visibility and join eligibility atomically require a fresh lease and fresh authenticated presence. (#6, #7) | Fake-clock expiry, renew/expire race, restart, and stale-host join tests. |
|
| Lease expiry removes abandoned servers | Visibility and join eligibility atomically require a fresh lease and fresh authenticated presence. (#6, #7) | Fake-clock expiry, renew/expire race, restart, and stale-host join tests. |
|
||||||
| Validate game, environment, room, and protocol boundaries | Every identifier is a validated type; store keys and authorization decisions include server-derived tenant scope; protocol is exact-match in v1. (#4-#10) | Contract, tenant-isolation, incompatible-version, and confused-deputy tests. |
|
| Validate game, environment, room, and protocol boundaries | Every identifier is a validated type; store keys and authorization decisions include server-derived tenant scope; protocol is exact-match in v1. (#4-#10) | Contract, tenant-isolation, incompatible-version, and confused-deputy tests. |
|
||||||
| Structured audit events without secrets or reusable credentials | Allowlisted audit schema excludes metadata values, raw endpoints, tokens, and key material; event volume is bounded. (#16) | Captured-log/audit assertions and credential canary scans. |
|
| Structured audit events without secrets or reusable credentials | Allowlisted audit schema excludes metadata values, raw endpoints, tokens, and key material; event volume is bounded. (#16) | Captured-log/audit assertions and credential canary scans. |
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ only its public key ID/lifecycle metadata and does not require retired secret
|
|||||||
material to remain available.
|
material to remain available.
|
||||||
|
|
||||||
Key IDs are non-secret base64url identifiers. Secret references are resolved
|
Key IDs are non-secret base64url identifiers. Secret references are resolved
|
||||||
through `ISecretProvider`; production supports `env:<VARIABLE>` references and
|
through `ISecretProvider`; production supports base64 `env:<VARIABLE>` and raw
|
||||||
the interface is replaceable by a deployment-specific vault/KMS adapter. The
|
`file:/absolute/path` references to bounded non-symlink files. The interface is
|
||||||
|
replaceable by a deployment-specific vault/KMS adapter. The
|
||||||
committed development profile uses an in-memory random key identified by a
|
committed development profile uses an in-memory random key identified by a
|
||||||
`development:ephemeral/...` reference. It never writes key material to disk and
|
`development:ephemeral/...` reference. It never writes key material to disk and
|
||||||
all credentials become invalid when the process exits.
|
all credentials become invalid when the process exits.
|
||||||
@@ -74,8 +75,10 @@ all credentials become invalid when the process exits.
|
|||||||
`Rendezvous:Provisioning` supplies issuer, audience, clock skew, signing-key
|
`Rendezvous:Provisioning` supplies issuer, audience, clock skew, signing-key
|
||||||
descriptors, and game policies. A production key reference such as
|
descriptors, and game policies. A production key reference such as
|
||||||
`env:RENDEZVOUS_SIGNING_KEY_2026_01` expects that environment variable to hold at
|
`env:RENDEZVOUS_SIGNING_KEY_2026_01` expects that environment variable to hold at
|
||||||
least 32 random bytes encoded as base64. Missing, malformed, short, inactive, or
|
least 32 random bytes encoded as base64. `file:/run/secrets/rendezvous-signing`
|
||||||
duplicate keys stop startup with a key-ID-only diagnostic. No game-wide secret
|
expects the raw bytes in a read-only, absolute, non-symlink file. Missing,
|
||||||
|
malformed, short, inactive, or duplicate keys stop startup with a key-ID-only
|
||||||
|
diagnostic. No game-wide secret
|
||||||
belongs in `appsettings`, source control, examples, the Client package, URLs,
|
belongs in `appsettings`, source control, examples, the Client package, URLs,
|
||||||
responses, logs, metrics, exceptions, or diagnostic dumps.
|
responses, logs, metrics, exceptions, or diagnostic dumps.
|
||||||
|
|
||||||
|
|||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROFILE="${RENDEZVOUS_CAPACITY_PROFILE:-quick}"
|
||||||
|
OUTPUT="${RENDEZVOUS_CAPACITY_OUTPUT:-$ROOT/artifacts/capacity/rendezvous-capacity-v2.json}"
|
||||||
|
CPUSET="${RENDEZVOUS_CAPACITY_CPUSET:-}"
|
||||||
|
PROJECT="$ROOT/tests/FinalFactory.Rendezvous.Capacity/FinalFactory.Rendezvous.Capacity.csproj"
|
||||||
|
TESTS="$ROOT/tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj"
|
||||||
|
|
||||||
|
if [[ "$PROFILE" != quick && "$PROFILE" != candidate ]]; then
|
||||||
|
printf 'RENDEZVOUS_CAPACITY_PROFILE must be quick or candidate.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
command -v dotnet >/dev/null || {
|
||||||
|
printf 'Missing required command: dotnet\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
if [[ -n "$CPUSET" ]]; then
|
||||||
|
command -v taskset >/dev/null || {
|
||||||
|
printf 'taskset is required when RENDEZVOUS_CAPACITY_CPUSET is set.\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
export RENDEZVOUS_EVIDENCE_COMMIT="$(git rev-parse HEAD)"
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
export RENDEZVOUS_EVIDENCE_TREE_STATE=dirty
|
||||||
|
else
|
||||||
|
export RENDEZVOUS_EVIDENCE_TREE_STATE=clean
|
||||||
|
fi
|
||||||
|
if [[ "$PROFILE" == candidate && "$RENDEZVOUS_EVIDENCE_TREE_STATE" != clean ]]; then
|
||||||
|
printf 'Candidate evidence requires a clean source tree.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
export RENDEZVOUS_EVIDENCE_CPUSET="${CPUSET:-unrestricted}"
|
||||||
|
export RENDEZVOUS_EVIDENCE_COMMAND="RENDEZVOUS_CAPACITY_PROFILE=$PROFILE RENDEZVOUS_CAPACITY_CPUSET=${CPUSET:-unrestricted} ./scripts/run-capacity-gate.sh"
|
||||||
|
|
||||||
|
dotnet restore "$ROOT/Rendezvous.slnx" --locked-mode
|
||||||
|
dotnet build "$ROOT/Rendezvous.slnx" --configuration Release --no-restore
|
||||||
|
|
||||||
|
filter='FullyQualifiedName~TrackerCapacityFailsClosedWithoutGrowingAndAWindowResetRecovers|FullyQualifiedName~OptionalTrafficCannotConsumeTheLeaseOperationReserve|FullyQualifiedName~ConcurrentAbusiveBurstStaysBoundedAndCannotBlockCriticalHttp|FullyQualifiedName~HttpOverloadIsTypedAndOversizedBodiesAreRejectedBeforeDispatch|FullyQualifiedName~WallClockMovementDoesNotExpireOrExtendLease|FullyQualifiedName~RepeatedMutableDeadlineRefreshesKeepOneScheduledEntryPerKey|FullyQualifiedName~RepeatedPrincipalRevocationCanExtendButCannotShortenProtection|FullyQualifiedName~RestartHasNewGenerationAndNoEphemeralState|FullyQualifiedName~RestartReturnsTypedUnavailabilityThenAllowsHostReregistration|FullyQualifiedName~DrainRejectsNewWorkAllowsInflightCompletionThenClearsState|FullyQualifiedName~UnavailableStoreFailsNewAuthorizationClosedAndErasesActiveState|FullyQualifiedName~KeyRotationHonorsOverlapAndRejectsRetiredKeys|FullyQualifiedName~OperatorSurfaceSeparatesAuthenticationConfirmsActionsAndRedactsInspection|FullyQualifiedName~SigtermDrainsThenReleasesHttpAndUdpSockets|FullyQualifiedName~ProductionTransportSoakKeepsHandlesMemoryAndSocketsBounded|FullyQualifiedName~NativeLiteNetLibRequestsIntroduceTheAuthorizedPair'
|
||||||
|
dotnet test "$TESTS" --configuration Release --no-build --filter "$filter" \
|
||||||
|
--logger 'console;verbosity=minimal'
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUTPUT")"
|
||||||
|
arguments=(
|
||||||
|
dotnet run --project "$PROJECT" --configuration Release --no-build --
|
||||||
|
--profile "$PROFILE" --output "$OUTPUT"
|
||||||
|
)
|
||||||
|
if [[ -n "$CPUSET" ]]; then
|
||||||
|
taskset -c "$CPUSET" "${arguments[@]}"
|
||||||
|
else
|
||||||
|
"${arguments[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Capacity and resilience gate passed; evidence: %s\n' "$OUTPUT"
|
||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SERVICE_URL="${RENDEZVOUS_SMOKE_HTTP_URL:-http://127.0.0.1:8080/}"
|
||||||
|
MEDIATOR="${RENDEZVOUS_SMOKE_UDP_ENDPOINT:-127.0.0.1:9050}"
|
||||||
|
TIMEOUT_SECONDS="${RENDEZVOUS_SMOKE_TIMEOUT_SECONDS:-30}"
|
||||||
|
LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}"
|
||||||
|
PROJECT="$ROOT/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj"
|
||||||
|
BUILD_CONFIGURATION="${RENDEZVOUS_SMOKE_CONFIGURATION:-Release}"
|
||||||
|
GAME_ID="${RENDEZVOUS_SMOKE_GAME_ID:-space-game}"
|
||||||
|
ENVIRONMENT_ID="${RENDEZVOUS_SMOKE_ENVIRONMENT_ID:-smoke}"
|
||||||
|
REGION="${RENDEZVOUS_SMOKE_REGION:-local}"
|
||||||
|
PROTOCOL_VERSION="${RENDEZVOUS_SMOKE_PROTOCOL_VERSION:-1}"
|
||||||
|
|
||||||
|
for command in curl date dotnet jq mktemp od openssl tail tr wc; do
|
||||||
|
command -v "$command" >/dev/null || {
|
||||||
|
printf 'Missing required command: %s\n' "$command" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || (( TIMEOUT_SECONDS < 1 || TIMEOUT_SECONDS > 300 )); then
|
||||||
|
printf 'RENDEZVOUS_SMOKE_TIMEOUT_SECONDS must be an integer from 1 through 300.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! "$PROTOCOL_VERSION" =~ ^[0-9]+$ ]] || (( PROTOCOL_VERSION < 1 )); then
|
||||||
|
printf 'RENDEZVOUS_SMOKE_PROTOCOL_VERSION must be a positive integer.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
for scoped_value in "$GAME_ID" "$ENVIRONMENT_ID" "$REGION"; do
|
||||||
|
if [[ -z "$scoped_value" ]]; then
|
||||||
|
printf 'Smoke game, environment, and region values must not be empty.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
base64url() {
|
||||||
|
openssl base64 -A | tr '+/' '-_' | tr -d '='
|
||||||
|
}
|
||||||
|
|
||||||
|
local_credential() {
|
||||||
|
if [[ ! -f "$LOCAL_KEY" ]] || [[ "$(wc -c < "$LOCAL_KEY")" -ne 32 ]]; then
|
||||||
|
printf 'Local Compose smoke key must be exactly 32 bytes: %s\n' "$LOCAL_KEY" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
local now expires nonce payload encoded signed hex signature
|
||||||
|
now="$(date +%s)"
|
||||||
|
expires="$((now + 600))"
|
||||||
|
nonce="$(openssl rand -hex 16)"
|
||||||
|
payload="$(jq -cn \
|
||||||
|
--arg issuer final-factory-rendezvous-smoke \
|
||||||
|
--arg audience rendezvous-service \
|
||||||
|
--arg subject local-smoke-host \
|
||||||
|
--arg kind dedicatedPublisher \
|
||||||
|
--arg gameId "$GAME_ID" \
|
||||||
|
--arg environmentId "$ENVIRONMENT_ID" \
|
||||||
|
--arg region "$REGION" \
|
||||||
|
--arg nonce "$nonce" \
|
||||||
|
--argjson now "$now" \
|
||||||
|
--argjson expires "$expires" \
|
||||||
|
'{version:1,issuer:$issuer,audience:$audience,subject:$subject,kind:$kind,gameId:$gameId,environmentId:$environmentId,regions:[$region],permissions:[],issuedAtUnixSeconds:$now,notBeforeUnixSeconds:$now,expiresAtUnixSeconds:$expires,nonce:$nonce}')"
|
||||||
|
encoded="$(printf '%s' "$payload" | base64url)"
|
||||||
|
signed="rv1.local-smoke-1.$encoded"
|
||||||
|
hex="$(od -An -v -tx1 "$LOCAL_KEY" | tr -d ' \n')"
|
||||||
|
signature="$(printf '%s' "$signed" \
|
||||||
|
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$hex" -binary \
|
||||||
|
| base64url)"
|
||||||
|
printf '%s.%s' "$signed" "$signature"
|
||||||
|
}
|
||||||
|
|
||||||
|
credential="${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}"
|
||||||
|
if [[ -z "$credential" ]]; then
|
||||||
|
credential="$(local_credential)"
|
||||||
|
fi
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$credential"
|
||||||
|
|
||||||
|
curl --fail --silent --show-error --max-time 5 "${SERVICE_URL%/}/health/live" >/dev/null
|
||||||
|
curl --fail --silent --show-error --max-time 5 "${SERVICE_URL%/}/health/ready" >/dev/null
|
||||||
|
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
host_log="$temp_dir/host.jsonl"
|
||||||
|
join_log="$temp_dir/join.jsonl"
|
||||||
|
host_pid=''
|
||||||
|
cleanup() {
|
||||||
|
local status="$?"
|
||||||
|
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
|
||||||
|
kill -TERM "$host_pid" 2>/dev/null || true
|
||||||
|
wait "$host_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ "$status" -ne 0 ]]; then
|
||||||
|
printf 'Deployment smoke failed; sanitized diagnostic events follow.\n' >&2
|
||||||
|
[[ -f "$host_log" ]] && jq -c . "$host_log" >&2 || true
|
||||||
|
[[ -f "$join_log" ]] && jq -c . "$join_log" >&2 || true
|
||||||
|
fi
|
||||||
|
rm -rf "$temp_dir"
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-build -- \
|
||||||
|
host --service "$SERVICE_URL" --mediator "$MEDIATOR" \
|
||||||
|
--game "$GAME_ID" --environment "$ENVIRONMENT_ID" --region "$REGION" --protocol "$PROTOCOL_VERSION" \
|
||||||
|
--script --json --exit-after-echo --timeout-seconds "$TIMEOUT_SECONDS" \
|
||||||
|
>"$host_log" 2>&1 &
|
||||||
|
host_pid="$!"
|
||||||
|
|
||||||
|
ready=false
|
||||||
|
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
|
||||||
|
if jq -e 'select(.event == "host.ready")' "$host_log" >/dev/null 2>&1; then
|
||||||
|
ready=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$host_pid" 2>/dev/null; then
|
||||||
|
printf 'Host diagnostic stopped before it became ready.\n' >&2
|
||||||
|
jq -c . "$host_log" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
if [[ "$ready" != true ]]; then
|
||||||
|
printf 'Host diagnostic did not become ready within %s seconds.\n' "$TIMEOUT_SECONDS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
listing_id="$(jq -r 'select(.event == "host.registered") | .listingId' "$host_log" | tail -n 1)"
|
||||||
|
if [[ -z "$listing_id" || "$listing_id" == null ]]; then
|
||||||
|
printf 'Host diagnostic did not report a listing ID.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-build -- \
|
||||||
|
join --service "$SERVICE_URL" --mediator "$MEDIATOR" \
|
||||||
|
--game "$GAME_ID" --environment "$ENVIRONMENT_ID" --region "$REGION" --protocol "$PROTOCOL_VERSION" \
|
||||||
|
--listing "$listing_id" --script --json --timeout-seconds "$TIMEOUT_SECONDS" \
|
||||||
|
>"$join_log" 2>&1
|
||||||
|
wait "$host_pid"
|
||||||
|
host_pid=''
|
||||||
|
|
||||||
|
jq -e 'select(.event == "host.direct-traffic" and .status == "verified")' "$host_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$host_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "join.direct-traffic" and .status == "verified")' "$join_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$join_log" >/dev/null
|
||||||
|
|
||||||
|
printf 'Rendezvous deployment smoke passed: HTTP live/ready and authenticated UDP mediation/direct traffic.\n'
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum RendezvousConnectionOutcomeSource
|
||||||
|
{
|
||||||
|
RendezvousService = 1,
|
||||||
|
LocalTraversal = 2,
|
||||||
|
RemoteHost = 3,
|
||||||
|
Caller = 4,
|
||||||
|
Lifecycle = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RendezvousConnectionFailureCategory
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Directory = 1,
|
||||||
|
Compatibility = 2,
|
||||||
|
Authorization = 3,
|
||||||
|
Capacity = 4,
|
||||||
|
HostPresence = 5,
|
||||||
|
Service = 6,
|
||||||
|
Mediation = 7,
|
||||||
|
NatTraversal = 8,
|
||||||
|
DirectConnection = 9,
|
||||||
|
Lifecycle = 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RendezvousConnectionPhase
|
||||||
|
{
|
||||||
|
Directory = 1,
|
||||||
|
Authorization = 2,
|
||||||
|
Mediation = 3,
|
||||||
|
NatTraversal = 4,
|
||||||
|
DirectConnection = 5,
|
||||||
|
Complete = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousConnectionOutcome
|
||||||
|
{
|
||||||
|
private readonly NetworkEndpoint? _dedicatedFallback;
|
||||||
|
|
||||||
|
private RendezvousConnectionOutcome(
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
TimeSpan elapsed,
|
||||||
|
RendezvousErrorCode? serviceError,
|
||||||
|
NetworkEndpoint? dedicatedFallback,
|
||||||
|
NetPeer? peer)
|
||||||
|
{
|
||||||
|
if (elapsed < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(elapsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dedicatedFallback is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
Kind = kind;
|
||||||
|
Source = source;
|
||||||
|
Category = category;
|
||||||
|
Phase = phase;
|
||||||
|
Elapsed = elapsed;
|
||||||
|
ServiceError = serviceError;
|
||||||
|
_dedicatedFallback = RendezvousEndpoint.Copy(dedicatedFallback);
|
||||||
|
Peer = peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConnectionOutcomeKind Kind { get; }
|
||||||
|
public RendezvousConnectionOutcomeSource Source { get; }
|
||||||
|
public RendezvousConnectionFailureCategory Category { get; }
|
||||||
|
public RendezvousConnectionPhase Phase { get; }
|
||||||
|
public TimeSpan Elapsed { get; }
|
||||||
|
public RendezvousErrorCode? ServiceError { get; }
|
||||||
|
public NetworkEndpoint? DedicatedFallback => RendezvousEndpoint.Copy(_dedicatedFallback);
|
||||||
|
public NetPeer? Peer { get; }
|
||||||
|
public bool IsSuccess => Kind == ConnectionOutcomeKind.Connected;
|
||||||
|
public bool HasDedicatedFallback => _dedicatedFallback is not null;
|
||||||
|
|
||||||
|
public static RendezvousConnectionOutcome FromServiceError(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
TimeSpan elapsed,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null)
|
||||||
|
{
|
||||||
|
if (error == RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A service failure outcome requires an error.", nameof(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
(ConnectionOutcomeKind kind, RendezvousConnectionFailureCategory category, RendezvousConnectionPhase phase) =
|
||||||
|
error switch
|
||||||
|
{
|
||||||
|
RendezvousErrorCode.NotFound => (
|
||||||
|
ConnectionOutcomeKind.DirectoryNotFound,
|
||||||
|
RendezvousConnectionFailureCategory.Directory,
|
||||||
|
RendezvousConnectionPhase.Directory),
|
||||||
|
RendezvousErrorCode.Expired => (
|
||||||
|
ConnectionOutcomeKind.AttemptExpired,
|
||||||
|
RendezvousConnectionFailureCategory.Authorization,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
RendezvousErrorCode.IncompatibleProtocol => (
|
||||||
|
ConnectionOutcomeKind.IncompatibleProtocol,
|
||||||
|
RendezvousConnectionFailureCategory.Compatibility,
|
||||||
|
RendezvousConnectionPhase.Directory),
|
||||||
|
RendezvousErrorCode.AuthenticationRequired
|
||||||
|
or RendezvousErrorCode.Forbidden
|
||||||
|
or RendezvousErrorCode.ReplayRejected => (
|
||||||
|
ConnectionOutcomeKind.Unauthorized,
|
||||||
|
RendezvousConnectionFailureCategory.Authorization,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
RendezvousErrorCode.RateLimited
|
||||||
|
or RendezvousErrorCode.CapacityExceeded => (
|
||||||
|
ConnectionOutcomeKind.RateLimited,
|
||||||
|
RendezvousConnectionFailureCategory.Capacity,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
RendezvousErrorCode.StaleHost => (
|
||||||
|
ConnectionOutcomeKind.NoHostPresence,
|
||||||
|
RendezvousConnectionFailureCategory.HostPresence,
|
||||||
|
RendezvousConnectionPhase.Mediation),
|
||||||
|
RendezvousErrorCode.ServiceUnavailable => (
|
||||||
|
ConnectionOutcomeKind.ServiceUnavailable,
|
||||||
|
RendezvousConnectionFailureCategory.Service,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
_ => (
|
||||||
|
ConnectionOutcomeKind.ServiceRejected,
|
||||||
|
RendezvousConnectionFailureCategory.Service,
|
||||||
|
RendezvousConnectionPhase.Authorization),
|
||||||
|
};
|
||||||
|
return new(
|
||||||
|
kind,
|
||||||
|
RendezvousConnectionOutcomeSource.RendezvousService,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
elapsed,
|
||||||
|
error,
|
||||||
|
dedicatedFallback,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConnectionElapsedBucket BucketElapsed(TimeSpan elapsed)
|
||||||
|
{
|
||||||
|
if (elapsed < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(elapsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return elapsed.TotalSeconds switch
|
||||||
|
{
|
||||||
|
< 1 => ConnectionElapsedBucket.UnderOneSecond,
|
||||||
|
< 5 => ConnectionElapsedBucket.OneToFiveSeconds,
|
||||||
|
< 15 => ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||||
|
< 30 => ConnectionElapsedBucket.FifteenToThirtySeconds,
|
||||||
|
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[RendezvousConnectionOutcome {Kind}; {Source}; credentials redacted]";
|
||||||
|
|
||||||
|
internal static RendezvousConnectionOutcome Create(
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
TimeSpan elapsed,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null,
|
||||||
|
NetPeer? peer = null) => new(
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
elapsed,
|
||||||
|
null,
|
||||||
|
dedicatedFallback,
|
||||||
|
peer);
|
||||||
|
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousConnectionStartResult
|
||||||
|
{
|
||||||
|
internal RendezvousConnectionStartResult(
|
||||||
|
CreateJoinAttemptResponse? attempt,
|
||||||
|
RendezvousConnectionOutcome? outcome)
|
||||||
|
{
|
||||||
|
if ((attempt is null) == (outcome is null))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"A connection start result requires exactly one attempt or terminal outcome.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Attempt = attempt;
|
||||||
|
Outcome = outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreateJoinAttemptResponse? Attempt { get; }
|
||||||
|
public RendezvousConnectionOutcome? Outcome { get; }
|
||||||
|
public bool IsReadyForTraversal => Attempt is not null;
|
||||||
|
public bool IsCompleted => Outcome is not null;
|
||||||
|
|
||||||
|
public static RendezvousConnectionStartResult ReadyForTraversal(
|
||||||
|
CreateJoinAttemptResponse attempt) => new(
|
||||||
|
attempt ?? throw new ArgumentNullException(nameof(attempt)),
|
||||||
|
null);
|
||||||
|
|
||||||
|
public static RendezvousConnectionStartResult Completed(
|
||||||
|
RendezvousConnectionOutcome outcome) => new(
|
||||||
|
null,
|
||||||
|
outcome ?? throw new ArgumentNullException(nameof(outcome)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum ConnectionTicketConsumptionResult
|
||||||
|
{
|
||||||
|
Accepted = 1,
|
||||||
|
NotFound = 2,
|
||||||
|
Expired = 3,
|
||||||
|
Rejected = 4,
|
||||||
|
AlreadyConsumed = 5,
|
||||||
|
Revoked = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ConnectionTicketValidator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<JoinAttemptId, TicketEntry> _tickets = [];
|
||||||
|
private readonly int _maximumAuthorizedTickets;
|
||||||
|
private readonly IConnectionTicketClock _clock;
|
||||||
|
private readonly byte[] _fingerprintKey = new byte[32];
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public ConnectionTicketValidator(int maximumAuthorizedTickets = 1_024)
|
||||||
|
: this(maximumAuthorizedTickets, new SystemConnectionTicketClock())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ConnectionTicketValidator(
|
||||||
|
int maximumAuthorizedTickets,
|
||||||
|
IConnectionTicketClock clock)
|
||||||
|
{
|
||||||
|
if (maximumAuthorizedTickets is < 1 or > 10_000)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maximumAuthorizedTickets));
|
||||||
|
}
|
||||||
|
|
||||||
|
_maximumAuthorizedTickets = maximumAuthorizedTickets;
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
RandomNumberGenerator.Fill(_fingerprintKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAuthorize(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string connectionTicket,
|
||||||
|
DateTimeOffset expiresAt)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(connectionTicket)
|
||||||
|
|| expiresAt <= now)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveExpired(now);
|
||||||
|
byte[] fingerprint = Fingerprint(connectionTicket);
|
||||||
|
if (_tickets.TryGetValue(attemptId, out TicketEntry? current))
|
||||||
|
{
|
||||||
|
bool idempotent = current.State == TicketState.Active
|
||||||
|
&& current.ExpiresAt == expiresAt
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(current.Fingerprint, fingerprint);
|
||||||
|
CryptographicOperations.ZeroMemory(fingerprint);
|
||||||
|
return idempotent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tickets.Count >= _maximumAuthorizedTickets)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(fingerprint);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickets.Add(attemptId, new(fingerprint, expiresAt));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConnectionTicketConsumptionResult Consume(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
|
||||||
|
{
|
||||||
|
RemoveExpired(now);
|
||||||
|
return ConnectionTicketConsumptionResult.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.ExpiresAt <= now)
|
||||||
|
{
|
||||||
|
Remove(attemptId, entry);
|
||||||
|
return ConnectionTicketConsumptionResult.Expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State == TicketState.Revoked)
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.Revoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State == TicketState.Consumed)
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.AlreadyConsumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] supplied = Fingerprint(connectionTicket);
|
||||||
|
bool matches = CryptographicOperations.FixedTimeEquals(entry.Fingerprint, supplied);
|
||||||
|
CryptographicOperations.ZeroMemory(supplied);
|
||||||
|
if (!matches)
|
||||||
|
{
|
||||||
|
return ConnectionTicketConsumptionResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.State = TicketState.Consumed;
|
||||||
|
return ConnectionTicketConsumptionResult.Accepted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Revoke(JoinAttemptId attemptId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
RemoveExpired(_clock.UtcNow);
|
||||||
|
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.State = TicketState.Revoked;
|
||||||
|
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (TicketEntry entry in _tickets.Values)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickets.Clear();
|
||||||
|
CryptographicOperations.ZeroMemory(_fingerprintKey);
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[ConnectionTicketValidator: tickets and key redacted]";
|
||||||
|
|
||||||
|
private byte[] Fingerprint(string ticket)
|
||||||
|
{
|
||||||
|
byte[] encoded = Encoding.ASCII.GetBytes(ticket);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HMACSHA256 hmac = new(_fingerprintKey);
|
||||||
|
return hmac.ComputeHash(encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveExpired(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
foreach (KeyValuePair<JoinAttemptId, TicketEntry> item in _tickets
|
||||||
|
.Where(item => item.Value.ExpiresAt <= now)
|
||||||
|
.ToArray())
|
||||||
|
{
|
||||||
|
Remove(item.Key, item.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Remove(JoinAttemptId attemptId, TicketEntry entry)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(entry.Fingerprint);
|
||||||
|
_tickets.Remove(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(ConnectionTicketValidator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TicketEntry(byte[] fingerprint, DateTimeOffset expiresAt)
|
||||||
|
{
|
||||||
|
public byte[] Fingerprint { get; } = fingerprint;
|
||||||
|
public DateTimeOffset ExpiresAt { get; } = expiresAt;
|
||||||
|
public TicketState State { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TicketState
|
||||||
|
{
|
||||||
|
Active = 0,
|
||||||
|
Consumed = 1,
|
||||||
|
Revoked = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IConnectionTicketClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemConnectionTicketClock : IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@
|
|||||||
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
|
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
|
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
|
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
||||||
<PackageReference Include="LiteNetLib" />
|
<PackageReference Include="LiteNetLib" />
|
||||||
|
<None Update="README.md" Pack="true" PackagePath="\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousJoinClient : IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
private const string LeaseTokenHeader = "X-Rendezvous-Lease-Token";
|
||||||
|
private const string ClientPunchCapabilityHeader = "X-Rendezvous-Client-Punch-Capability";
|
||||||
|
|
||||||
|
private readonly RendezvousHttpTransport _transport;
|
||||||
|
|
||||||
|
public RendezvousJoinClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options = null,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_transport = new(httpClient, options, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
CreateJoinAttemptRequest body = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
ListingId = request.ListingId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
};
|
||||||
|
return _transport.SendSafeAsync<CreateJoinAttemptResponse>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (dedicatedFallback is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RendezvousClientResult<CreateJoinAttemptResponse> result = await CreateAsync(
|
||||||
|
request,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
elapsed.Stop();
|
||||||
|
return result.IsSuccess && result.Value is not null
|
||||||
|
? RendezvousConnectionStartResult.ReadyForTraversal(result.Value)
|
||||||
|
: RendezvousConnectionStartResult.Completed(
|
||||||
|
RendezvousConnectionOutcome.FromServiceError(
|
||||||
|
result.Error,
|
||||||
|
elapsed.Elapsed,
|
||||||
|
dedicatedFallback));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
elapsed.Stop();
|
||||||
|
return RendezvousConnectionStartResult.Completed(
|
||||||
|
RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.Caller,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Authorization,
|
||||||
|
elapsed.Elapsed,
|
||||||
|
dedicatedFallback));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (attempt is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(attempt));
|
||||||
|
}
|
||||||
|
return _transport.SendSafeAsync<bool>(
|
||||||
|
() => HeaderRequest(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/join-attempts/{attempt.AttemptId}",
|
||||||
|
ClientPunchCapabilityHeader,
|
||||||
|
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
if (pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
string query = $"v1/sessions/{session.ListingId}/join-attempts"
|
||||||
|
+ $"?contractVersion={ContractLimits.ContractVersion}"
|
||||||
|
+ $"&pageSize={pageSize}"
|
||||||
|
+ (cursor is null ? string.Empty : $"&cursor={Uri.EscapeDataString(cursor)}");
|
||||||
|
return _transport.SendSafeAsync<BrowseHostJoinAttemptsResponse>(
|
||||||
|
() => HeaderRequest(
|
||||||
|
HttpMethod.Get,
|
||||||
|
query,
|
||||||
|
LeaseTokenHeader,
|
||||||
|
RequireHeaderValue(session.LeaseToken, nameof(session))),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
if (maximumPages is < 1 or > 1_000)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maximumPages));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<HostJoinAttempt> attempts = [];
|
||||||
|
string? cursor = null;
|
||||||
|
for (int page = 0; page < maximumPages; page++)
|
||||||
|
{
|
||||||
|
RendezvousClientResult<BrowseHostJoinAttemptsResponse> result =
|
||||||
|
await BrowseForHostAsync(
|
||||||
|
session,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
cursor,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.AddRange(result.Value.Items);
|
||||||
|
cursor = result.Value.NextCursor;
|
||||||
|
if (string.IsNullOrEmpty(cursor))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Success<IReadOnlyList<HostJoinAttempt>>(
|
||||||
|
attempts.AsReadOnly());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
$"Host invitation polling exceeded the configured {maximumPages}-page limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousConnectionOutcome outcome,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (attempt is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(attempt));
|
||||||
|
}
|
||||||
|
if (outcome is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(outcome));
|
||||||
|
}
|
||||||
|
if (!ContractValidation.IsReportableConnectionOutcome(outcome.Kind))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"This outcome cannot be reported for an issued join attempt.",
|
||||||
|
nameof(outcome));
|
||||||
|
}
|
||||||
|
|
||||||
|
ReportConnectionOutcomeRequest body = new()
|
||||||
|
{
|
||||||
|
Outcome = outcome.Kind,
|
||||||
|
ElapsedBucket = RendezvousConnectionOutcome.BucketElapsed(outcome.Elapsed),
|
||||||
|
};
|
||||||
|
return _transport.SendSafeAsync<ReportConnectionOutcomeResponse>(
|
||||||
|
() => HeaderJsonRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"v1/join-attempts/{attempt.AttemptId}/outcome",
|
||||||
|
ClientPunchCapabilityHeader,
|
||||||
|
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt)),
|
||||||
|
body),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage HeaderRequest(
|
||||||
|
HttpMethod method,
|
||||||
|
string uri,
|
||||||
|
string header,
|
||||||
|
string value)
|
||||||
|
{
|
||||||
|
HttpRequestMessage request = new(method, uri);
|
||||||
|
request.Headers.TryAddWithoutValidation(header, value);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage HeaderJsonRequest<T>(
|
||||||
|
HttpMethod method,
|
||||||
|
string uri,
|
||||||
|
string header,
|
||||||
|
string value,
|
||||||
|
T body)
|
||||||
|
{
|
||||||
|
HttpRequestMessage request = RendezvousHttpTransport.JsonRequest(method, uri, body);
|
||||||
|
request.Headers.TryAddWithoutValidation(header, value);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RequireHeaderValue(string value, string parameterName) =>
|
||||||
|
!string.IsNullOrWhiteSpace(value)
|
||||||
|
? value
|
||||||
|
: throw new ArgumentException("The required capability is missing.", parameterName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# FinalFactory.Rendezvous.Client
|
||||||
|
|
||||||
|
Godot-independent .NET publisher, browser, join, and LiteNetLib traversal SDK for Rendezvous v1.
|
||||||
|
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
using HttpClient http = new()
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://rendezvous.example/"),
|
||||||
|
};
|
||||||
|
|
||||||
|
string publisherCredential = Environment.GetEnvironmentVariable(
|
||||||
|
"RENDEZVOUS_PUBLISHER_CREDENTIAL")
|
||||||
|
?? throw new InvalidOperationException("Publisher credential is not configured.");
|
||||||
|
CancellationToken cancellationToken = default;
|
||||||
|
RendezvousPublisherClient publisher = new(http);
|
||||||
|
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||||
|
new RegisterSessionRequest
|
||||||
|
{
|
||||||
|
IdempotencyKey = Guid.NewGuid().ToString("N"),
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "My server",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||||
|
DedicatedFallback = new()
|
||||||
|
{
|
||||||
|
AddressFamily = AddressFamilyKind.Ipv4,
|
||||||
|
Address = "203.0.113.40",
|
||||||
|
Port = 7777,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
publisherCredential,
|
||||||
|
cancellationToken);
|
||||||
|
if (!registered.IsSuccess || registered.Value is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Registration failed: {registered.Error} ({registered.Message})");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Load `publisherCredential` from the game's deployment secret boundary; never
|
||||||
|
embed it in a client build or source control. A successful registration returns a
|
||||||
|
`PublishedSession` containing the lease and host-presence capabilities.
|
||||||
|
Send a periodic presence request from the host's gameplay `NetManager` using the
|
||||||
|
server-controlled refresh interval and the fixed-size native token:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
string presenceToken = NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.HostPresence,
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
session.HostPresenceCapability);
|
||||||
|
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
For direct connections, let the SDK drive those tokens from the same caller-owned
|
||||||
|
LiteNetLib socket that carries gameplay. Ask the routing listener to create the
|
||||||
|
bound manager, then configure and start that caller-owned manager yourself. The
|
||||||
|
factory does not open a socket, and synchronized events must remain enabled:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager gameplayNetManager = networkEvents.CreateManager();
|
||||||
|
if (!gameplayNetManager.Start(0))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The gameplay UDP socket could not start.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The host polls join invitations asynchronously; that method only queues a
|
||||||
|
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
|
||||||
|
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
|
||||||
|
frame on the thread that owns the manager:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousJoinClient joins = new(http);
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
gameplayNetManager,
|
||||||
|
networkEvents,
|
||||||
|
mediatorEndPoint,
|
||||||
|
session,
|
||||||
|
joins);
|
||||||
|
|
||||||
|
// Run periodically from the game's normal async scheduling path.
|
||||||
|
await host.RefreshJoinAttemptsAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Godot _Process, Update, or the equivalent main-thread frame callback.
|
||||||
|
host.Poll();
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not also call `gameplayNetManager.PollEvents()` or
|
||||||
|
`gameplayNetManager.NatPunchModule.PollEvents()` when a coordinator owns polling.
|
||||||
|
The host coordinator refreshes host presence, punches for queued invitations,
|
||||||
|
validates the introduction ticket, and accepts the direct request. Subscribe to
|
||||||
|
`AttemptCompleted`; a `Connected` result is raised only after LiteNetLib reports
|
||||||
|
the accepted peer as connected. Register ordinary gameplay callbacks on
|
||||||
|
`networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct
|
||||||
|
requests for ticket validation and forwards every other callback normally.
|
||||||
|
|
||||||
|
The joining game first requests an attempt through the typed start API. It returns
|
||||||
|
exactly one issued attempt or one terminal service outcome, so service authority
|
||||||
|
is not confused with a later locally observed traversal failure:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync(
|
||||||
|
createJoinRequest,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (start.Outcome is { } serviceOutcome)
|
||||||
|
{
|
||||||
|
ShowConnectionFailure(serviceOutcome.Kind, serviceOutcome.Category);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateJoinAttemptResponse attempt = start.Attempt
|
||||||
|
?? throw new InvalidOperationException("The typed start result was invalid.");
|
||||||
|
using RendezvousClientCoordinator client = new(
|
||||||
|
gameplayNetManager,
|
||||||
|
networkEvents,
|
||||||
|
mediatorEndPoint,
|
||||||
|
attempt);
|
||||||
|
|
||||||
|
// Godot _Process, Update, or the equivalent main-thread frame callback.
|
||||||
|
client.Poll();
|
||||||
|
```
|
||||||
|
|
||||||
|
NAT introduction changes the client state to `Connecting`; it is not success.
|
||||||
|
Only a `Connected` outcome supplies `Peer`. Completion exposes a stable kind,
|
||||||
|
source, category, phase, and elapsed duration. The default HTTP silence, punch,
|
||||||
|
and direct-connect budgets are five, ten, and five seconds respectively; configure
|
||||||
|
them through `RendezvousClientOptions` and `RendezvousCoordinatorOptions` when a
|
||||||
|
game has measured reasons to do so. The signed attempt expiry is always the
|
||||||
|
absolute upper bound.
|
||||||
|
|
||||||
|
Call `Cancel()` and then `Poll()` for local cancellation, or
|
||||||
|
`CancelAsync(joins, cancellationToken)` to also revoke the service attempt.
|
||||||
|
Terminal client paths complete exactly once and release all event subscriptions,
|
||||||
|
so late packets and callbacks are inert. Disposing a coordinator never stops or
|
||||||
|
disposes the caller-owned manager and does not touch an in-flight peer; call
|
||||||
|
`Cancel()` followed by `Poll()` first when that peer must also be disconnected.
|
||||||
|
|
||||||
|
After terminal completion, reporting is explicit and safe to retry. It sends only
|
||||||
|
the authenticated outcome enum and a coarse elapsed bucket—never the endpoint,
|
||||||
|
exact duration, diagnostic text, metadata, or player identity:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousClientResult<ReportConnectionOutcomeResponse> report =
|
||||||
|
await client.ReportOutcomeAsync(joins, cancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
An optional `DedicatedFallback` is copied from the authoritative listing into the
|
||||||
|
issued attempt and terminal outcome. A local deployment may replace it with
|
||||||
|
`RendezvousCoordinatorOptions.DedicatedFallbackOverride`. The SDK only returns
|
||||||
|
the endpoint; it never connects automatically. The game must explicitly decide
|
||||||
|
whether to use it and then connect and authenticate through its own gameplay
|
||||||
|
transport. If the outcome has no fallback, v1 offers no relay.
|
||||||
|
|
||||||
|
Lease renewal is explicit and caller-controlled:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
PublishedSession session = registered.Value;
|
||||||
|
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||||
|
session,
|
||||||
|
publisherCredential);
|
||||||
|
LeaseMaintenanceResult stopped = await maintainer.RunAsync(cancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
Creating the maintainer does not start background work. Await its run and dispose
|
||||||
|
it when hosting stops. Use `IRendezvousPublisherClient` and
|
||||||
|
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
|
||||||
|
the requests and responses it creates but never disposes the supplied `HttpClient`.
|
||||||
|
|
||||||
|
The host-side `ConnectionTicketValidator` is a bounded, thread-safe one-time gate.
|
||||||
|
Authorize only tickets delivered by the authenticated Rendezvous introduction,
|
||||||
|
then consume the exact ticket presented by the direct LiteNetLib connection:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ConnectionTicketValidator tickets = new();
|
||||||
|
tickets.TryAuthorize(attemptId, expectedTicket, expiresAt);
|
||||||
|
ConnectionTicketConsumptionResult admission = tickets.Consume(
|
||||||
|
attemptId,
|
||||||
|
presentedTicket);
|
||||||
|
```
|
||||||
|
|
||||||
|
An `Accepted` ticket authorizes only this connection attempt. The game must still
|
||||||
|
apply its own player identity, capacity, ban, and gameplay admission rules. Revoke
|
||||||
|
the attempt on cancellation and dispose the validator during host shutdown so its
|
||||||
|
keyed ticket digests are zeroed.
|
||||||
|
|
||||||
|
See the repository's ADR 0007 for HTTP ownership/retry semantics, ADR 0008 for
|
||||||
|
join-capability and connection-ticket security semantics, and ADR 0010 for typed
|
||||||
|
outcomes, deadlines, reporting, and caller-owned fallback.
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientResult<T>
|
||||||
|
{
|
||||||
|
internal RendezvousClientResult(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
T? value,
|
||||||
|
string message,
|
||||||
|
int? retryAfterSeconds)
|
||||||
|
{
|
||||||
|
Error = error;
|
||||||
|
Value = value;
|
||||||
|
Message = message;
|
||||||
|
RetryAfterSeconds = retryAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsSuccess => Error == RendezvousErrorCode.None;
|
||||||
|
public RendezvousErrorCode Error { get; }
|
||||||
|
public T? Value { get; }
|
||||||
|
public string Message { get; }
|
||||||
|
public int? RetryAfterSeconds { get; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RendezvousClientResult
|
||||||
|
{
|
||||||
|
public static RendezvousClientResult<T> Success<T>(T value) =>
|
||||||
|
value is null
|
||||||
|
? throw new ArgumentNullException(nameof(value))
|
||||||
|
: new(RendezvousErrorCode.None, value, string.Empty, null);
|
||||||
|
|
||||||
|
public static RendezvousClientResult<T> Failure<T>(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
string message,
|
||||||
|
int? retryAfterSeconds = null) =>
|
||||||
|
error == RendezvousErrorCode.None
|
||||||
|
? throw new ArgumentException("A failure requires a non-success error.", nameof(error))
|
||||||
|
: new(error, default, message ?? string.Empty, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PublishedSession
|
||||||
|
{
|
||||||
|
private readonly object _timingGate = new();
|
||||||
|
private DateTimeOffset _expiresAt;
|
||||||
|
private int _leaseRenewAfterSeconds;
|
||||||
|
|
||||||
|
internal PublishedSession(RegisterSessionResponse response)
|
||||||
|
{
|
||||||
|
ListingId = response.ListingId;
|
||||||
|
LeaseId = response.LeaseId;
|
||||||
|
LeaseToken = response.LeaseToken;
|
||||||
|
HostPresenceHandle = response.HostPresenceHandle;
|
||||||
|
HostPresenceCapability = response.HostPresenceCapability;
|
||||||
|
_expiresAt = response.ExpiresAt;
|
||||||
|
_leaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||||
|
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionListingId ListingId { get; }
|
||||||
|
public LeaseId LeaseId { get; }
|
||||||
|
public string LeaseToken { get; }
|
||||||
|
public MediationHandle HostPresenceHandle { get; }
|
||||||
|
public string HostPresenceCapability { get; }
|
||||||
|
public DateTimeOffset ExpiresAt
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
return _expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal set
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
_expiresAt = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int LeaseRenewAfterSeconds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
return _leaseRenewAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal set
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
_leaseRenewAfterSeconds = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public int HostPresenceRefreshAfterSeconds { get; }
|
||||||
|
|
||||||
|
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousPublisherClient
|
||||||
|
{
|
||||||
|
Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||||
|
RegisterSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
UpdateSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousSessionBrowserClient
|
||||||
|
{
|
||||||
|
Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||||
|
SessionListingId listingId,
|
||||||
|
GameId gameId,
|
||||||
|
EnvironmentId environmentId,
|
||||||
|
uint protocolVersion,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
NetworkEndpoint? dedicatedFallback = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousConnectionOutcome outcome,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousDelay
|
||||||
|
{
|
||||||
|
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousClientOptions
|
||||||
|
{
|
||||||
|
public int MaximumSafeRetries { get; set; } = 2;
|
||||||
|
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||||
|
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||||
|
public double JitterRatio { get; set; } = 0.2;
|
||||||
|
|
||||||
|
internal void Validate()
|
||||||
|
{
|
||||||
|
if (MaximumSafeRetries is < 0 or > 5
|
||||||
|
|| RequestTimeout <= TimeSpan.Zero
|
||||||
|
|| RequestTimeout > TimeSpan.FromSeconds(30)
|
||||||
|
|| InitialRetryDelay < TimeSpan.Zero
|
||||||
|
|| MaximumRetryDelay < InitialRetryDelay
|
||||||
|
|| MaximumRetryDelay > TimeSpan.FromSeconds(30)
|
||||||
|
|| JitterRatio is < 0 or > 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(RendezvousClientOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemRendezvousDelay : IRendezvousDelay
|
||||||
|
{
|
||||||
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
|
||||||
|
Task.Delay(delay, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RendezvousEndpoint
|
||||||
|
{
|
||||||
|
internal static NetworkEndpoint? Copy(NetworkEndpoint? endpoint) => endpoint is null
|
||||||
|
? null
|
||||||
|
: new NetworkEndpoint
|
||||||
|
{
|
||||||
|
AddressFamily = endpoint.AddressFamily,
|
||||||
|
Address = endpoint.Address,
|
||||||
|
Port = endpoint.Port,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
internal sealed class RendezvousHttpTransport
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly RendezvousClientOptions _options;
|
||||||
|
private readonly IRendezvousDelay _delay;
|
||||||
|
|
||||||
|
internal RendezvousHttpTransport(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options,
|
||||||
|
IRendezvousDelay? delay)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||||
|
RendezvousClientOptions suppliedOptions = options ?? new RendezvousClientOptions();
|
||||||
|
suppliedOptions.Validate();
|
||||||
|
_options = new RendezvousClientOptions
|
||||||
|
{
|
||||||
|
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
|
||||||
|
RequestTimeout = suppliedOptions.RequestTimeout,
|
||||||
|
InitialRetryDelay = suppliedOptions.InitialRetryDelay,
|
||||||
|
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
|
||||||
|
JitterRatio = suppliedOptions.JitterRatio,
|
||||||
|
};
|
||||||
|
_delay = delay ?? new SystemRendezvousDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<RendezvousClientResult<T>> SendSafeAsync<T>(
|
||||||
|
Func<HttpRequestMessage> requestFactory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (int attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
using CancellationTokenSource requestTimeout =
|
||||||
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
requestTimeout.CancelAfter(_options.RequestTimeout);
|
||||||
|
CancellationToken requestCancellation = requestTimeout.Token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpRequestMessage request = requestFactory();
|
||||||
|
using HttpResponseMessage response = await _httpClient
|
||||||
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestCancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
if (typeof(T) == typeof(bool) && response.StatusCode == HttpStatusCode.NoContent)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Success((T)(object)true);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = await ReadBoundedAsync(response.Content, requestCancellation)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<T>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an oversized success response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
T? value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
value = JsonSerializer.Deserialize<T>(payload, ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value is null
|
||||||
|
? RendezvousClientResult.Failure<T>(
|
||||||
|
RendezvousErrorCode.InternalError,
|
||||||
|
"The service returned an invalid success response.")
|
||||||
|
: RendezvousClientResult.Success(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
|
||||||
|
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||||
|
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
|
||||||
|
{
|
||||||
|
await _delay.DelayAsync(
|
||||||
|
GetRetryDelay(attempt, retryAfter),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Failure<T>(error.Code, error.Message, retryAfter);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
IsTransientTransportFailure(exception, cancellationToken)
|
||||||
|
&& attempt < _options.MaximumSafeRetries)
|
||||||
|
{
|
||||||
|
await _delay.DelayAsync(GetRetryDelay(attempt, null), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<T>(
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
"The Rendezvous service did not return a valid response.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static HttpRequestMessage JsonRequest<T>(
|
||||||
|
HttpMethod method,
|
||||||
|
string uri,
|
||||||
|
T body,
|
||||||
|
string? publisherCredential = null)
|
||||||
|
{
|
||||||
|
HttpRequestMessage request = new(method, uri)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(body, ContractJson.Options),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"),
|
||||||
|
};
|
||||||
|
if (publisherCredential is not null)
|
||||||
|
{
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||||
|
"Bearer",
|
||||||
|
RequireCredential(publisherCredential));
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string RequireCredential(string credential) =>
|
||||||
|
!string.IsNullOrWhiteSpace(credential)
|
||||||
|
? credential
|
||||||
|
: throw new ArgumentException("A publisher credential is required.", nameof(credential));
|
||||||
|
|
||||||
|
private static async Task<ApiError> ReadErrorAsync(
|
||||||
|
HttpResponseMessage response,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ApiError? error = JsonSerializer.Deserialize<ApiError>(payload, ContractJson.Options);
|
||||||
|
return error is not null && error.Code != RendezvousErrorCode.None
|
||||||
|
? error
|
||||||
|
: FallbackError(response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is JsonException or InvalidDataException)
|
||||||
|
{
|
||||||
|
return FallbackError(response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> ReadBoundedAsync(
|
||||||
|
HttpContent content,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using Stream source = await content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||||
|
using MemoryStream destination = new();
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int read = await source.ReadAsync(buffer.AsMemory(), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
return destination.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.Length + read > ContractLimits.BrowserResponseMaxBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("The service response exceeded the SDK limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSpan GetRetryDelay(int attempt, int? retryAfterSeconds)
|
||||||
|
{
|
||||||
|
TimeSpan basis = retryAfterSeconds.HasValue
|
||||||
|
? TimeSpan.FromSeconds(Math.Max(0, retryAfterSeconds.Value))
|
||||||
|
: TimeSpan.FromMilliseconds(
|
||||||
|
_options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt));
|
||||||
|
double bounded = Math.Min(basis.TotalMilliseconds, _options.MaximumRetryDelay.TotalMilliseconds);
|
||||||
|
if (_options.JitterRatio == 0 || bounded == 0)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromMilliseconds(bounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] random = new byte[1];
|
||||||
|
RandomNumberGenerator.Fill(random);
|
||||||
|
double unit = random[0] / 255d;
|
||||||
|
double multiplier = 1 - _options.JitterRatio + (2 * _options.JitterRatio * unit);
|
||||||
|
return TimeSpan.FromMilliseconds(Math.Min(
|
||||||
|
bounded * multiplier,
|
||||||
|
_options.MaximumRetryDelay.TotalMilliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTransient(RendezvousErrorCode code) => code is
|
||||||
|
RendezvousErrorCode.RateLimited
|
||||||
|
or RendezvousErrorCode.CapacityExceeded
|
||||||
|
or RendezvousErrorCode.ServiceUnavailable;
|
||||||
|
|
||||||
|
private static bool IsTransientTransportFailure(
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken callerCancellation) =>
|
||||||
|
exception is HttpRequestException
|
||||||
|
or IOException
|
||||||
|
|| exception is OperationCanceledException && !callerCancellation.IsCancellationRequested;
|
||||||
|
|
||||||
|
private static int? GetRetryAfterSeconds(RetryConditionHeaderValue? retryAfter) =>
|
||||||
|
retryAfter?.Delta is TimeSpan delta
|
||||||
|
? Math.Max(0, (int)Math.Ceiling(delta.TotalSeconds))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static ApiError FallbackError(HttpStatusCode statusCode) => new()
|
||||||
|
{
|
||||||
|
Code = statusCode switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.BadRequest => RendezvousErrorCode.InvalidRequest,
|
||||||
|
HttpStatusCode.Unauthorized => RendezvousErrorCode.AuthenticationRequired,
|
||||||
|
HttpStatusCode.Forbidden => RendezvousErrorCode.Forbidden,
|
||||||
|
HttpStatusCode.NotFound => RendezvousErrorCode.NotFound,
|
||||||
|
HttpStatusCode.Conflict => RendezvousErrorCode.Conflict,
|
||||||
|
HttpStatusCode.Gone => RendezvousErrorCode.Expired,
|
||||||
|
HttpStatusCode.TooManyRequests => RendezvousErrorCode.RateLimited,
|
||||||
|
HttpStatusCode.RequestTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.BadGateway => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.GatewayTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
_ => RendezvousErrorCode.InternalError,
|
||||||
|
},
|
||||||
|
Message = "The service returned an error without a valid Rendezvous envelope.",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
||||||
|
{
|
||||||
|
private readonly RendezvousHttpTransport _transport;
|
||||||
|
private readonly IRendezvousDelay _delay;
|
||||||
|
|
||||||
|
public RendezvousPublisherClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options = null,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_delay = delay ?? new SystemRendezvousDelay();
|
||||||
|
_transport = new(httpClient, options, _delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||||
|
RegisterSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterSessionRequest body = CopyRegistration(request);
|
||||||
|
RendezvousClientResult<RegisterSessionResponse> result = await _transport.SendSafeAsync<RegisterSessionResponse>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/sessions", body, publisherCredential),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return result.IsSuccess && result.Value is not null
|
||||||
|
? RendezvousClientResult.Success(new PublishedSession(result.Value))
|
||||||
|
: RendezvousClientResult.Failure<PublishedSession>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousClientResult<RenewLeaseResponse> result = await _transport.SendSafeAsync<RenewLeaseResponse>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"v1/sessions/{session.ListingId}/renew",
|
||||||
|
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
|
||||||
|
publisherCredential),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (result.IsSuccess && result.Value is not null)
|
||||||
|
{
|
||||||
|
session.ExpiresAt = result.Value.ExpiresAt;
|
||||||
|
session.LeaseRenewAfterSeconds = result.Value.RenewAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
UpdateSessionRequest request,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateSessionRequest body = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
LeaseToken = session.LeaseToken,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Capacity = CopyCapacity(request.Capacity),
|
||||||
|
Metadata = CopyMetadata(request.Metadata),
|
||||||
|
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
|
||||||
|
};
|
||||||
|
return _transport.SendSafeAsync<bool>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(
|
||||||
|
HttpMethod.Put,
|
||||||
|
$"v1/sessions/{session.ListingId}",
|
||||||
|
body,
|
||||||
|
publisherCredential),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
return _transport.SendSafeAsync<bool>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/sessions/{session.ListingId}",
|
||||||
|
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
|
||||||
|
publisherCredential),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionLeaseMaintainer CreateLeaseMaintainer(
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential) => new(
|
||||||
|
this,
|
||||||
|
session ?? throw new ArgumentNullException(nameof(session)),
|
||||||
|
RendezvousHttpTransport.RequireCredential(publisherCredential),
|
||||||
|
_delay);
|
||||||
|
|
||||||
|
private static RegisterSessionRequest CopyRegistration(RegisterSessionRequest request) => new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Visibility = request.Visibility,
|
||||||
|
Capacity = CopyCapacity(request.Capacity),
|
||||||
|
Metadata = CopyMetadata(request.Metadata),
|
||||||
|
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new()
|
||||||
|
{
|
||||||
|
CurrentPlayers = capacity.CurrentPlayers,
|
||||||
|
MaximumPlayers = capacity.MaximumPlayers,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) =>
|
||||||
|
new(metadata, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserClient
|
||||||
|
{
|
||||||
|
private readonly RendezvousHttpTransport _transport;
|
||||||
|
|
||||||
|
public RendezvousSessionBrowserClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options = null,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_transport = new(httpClient, options, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
string query = $"v1/sessions?contractVersion={request.ContractVersion}"
|
||||||
|
+ $"&gameId={Escape(request.GameId.Value)}"
|
||||||
|
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
|
||||||
|
+ $"&protocolVersion={request.ProtocolVersion}"
|
||||||
|
+ $"&pageSize={request.PageSize}"
|
||||||
|
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
|
||||||
|
+ (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty)
|
||||||
|
+ (request.Cursor is not null ? $"&cursor={Escape(request.Cursor)}" : string.Empty);
|
||||||
|
return _transport.SendSafeAsync<BrowseSessionsResponse>(
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maximumPages is < 1 or > 1000)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maximumPages));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SessionListing> items = [];
|
||||||
|
string? cursor = request.Cursor;
|
||||||
|
for (int page = 0; page < maximumPages; page++)
|
||||||
|
{
|
||||||
|
BrowseSessionsRequest pageRequest = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
PageSize = request.PageSize,
|
||||||
|
ExcludeFull = request.ExcludeFull,
|
||||||
|
Cursor = cursor,
|
||||||
|
};
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> result = await BrowseAsync(
|
||||||
|
pageRequest,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.AddRange(result.Value.Items);
|
||||||
|
cursor = result.Value.NextCursor;
|
||||||
|
if (string.IsNullOrEmpty(cursor))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Success<IReadOnlyList<SessionListing>>(items.AsReadOnly());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
$"Browsing exceeded the configured {maximumPages}-page limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||||
|
SessionListingId listingId,
|
||||||
|
GameId gameId,
|
||||||
|
EnvironmentId environmentId,
|
||||||
|
uint protocolVersion,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string query = $"v1/sessions/{listingId}?contractVersion={ContractLimits.ContractVersion}"
|
||||||
|
+ $"&gameId={Escape(gameId.Value)}"
|
||||||
|
+ $"&environmentId={Escape(environmentId.Value)}"
|
||||||
|
+ $"&protocolVersion={protocolVersion}";
|
||||||
|
return _transport.SendSafeAsync<GetSessionResponse>(
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum LeaseMaintenanceStopReason
|
||||||
|
{
|
||||||
|
Cancelled = 1,
|
||||||
|
Disposed = 2,
|
||||||
|
LeaseLost = 3,
|
||||||
|
Failed = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LeaseMaintenanceResult
|
||||||
|
{
|
||||||
|
internal LeaseMaintenanceResult(LeaseMaintenanceStopReason reason, RendezvousErrorCode error)
|
||||||
|
{
|
||||||
|
Reason = reason;
|
||||||
|
Error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeaseMaintenanceStopReason Reason { get; }
|
||||||
|
public RendezvousErrorCode Error { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SessionLeaseMaintainer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly IRendezvousPublisherClient _publisher;
|
||||||
|
private readonly PublishedSession _session;
|
||||||
|
private readonly string _publisherCredential;
|
||||||
|
private readonly IRendezvousDelay _delay;
|
||||||
|
private readonly CancellationTokenSource _disposeCancellation = new();
|
||||||
|
private Task<LeaseMaintenanceResult>? _activeRun;
|
||||||
|
private Task? _disposeTask;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal SessionLeaseMaintainer(
|
||||||
|
IRendezvousPublisherClient publisher,
|
||||||
|
PublishedSession session,
|
||||||
|
string publisherCredential,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_publisher = publisher;
|
||||||
|
_session = session;
|
||||||
|
_publisherCredential = publisherCredential;
|
||||||
|
_delay = delay ?? new SystemRendezvousDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? LeaseLost;
|
||||||
|
|
||||||
|
public Task<LeaseMaintenanceResult> RunAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(SessionLeaseMaintainer));
|
||||||
|
}
|
||||||
|
if (_activeRun is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Lease maintenance is already running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeRun = RunCoreAsync(cancellationToken);
|
||||||
|
return _activeRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposeTask is not null)
|
||||||
|
{
|
||||||
|
return new(_disposeTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_disposeCancellation.Cancel();
|
||||||
|
_disposeTask = FinishDisposeAsync(_activeRun);
|
||||||
|
return new(_disposeTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinishDisposeAsync(Task<LeaseMaintenanceResult>? active)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (active is not null)
|
||||||
|
{
|
||||||
|
await active.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_disposeCancellation.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LeaseMaintenanceResult> RunCoreAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken,
|
||||||
|
_disposeCancellation.Token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
await _delay.DelayAsync(
|
||||||
|
TimeSpan.FromSeconds(Math.Max(1, _session.LeaseRenewAfterSeconds)),
|
||||||
|
linked.Token).ConfigureAwait(false);
|
||||||
|
RendezvousClientResult<RenewLeaseResponse> renewed = await _publisher.RenewAsync(
|
||||||
|
_session,
|
||||||
|
_publisherCredential,
|
||||||
|
linked.Token).ConfigureAwait(false);
|
||||||
|
if (renewed.IsSuccess)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renewed.Error is RendezvousErrorCode.NotFound
|
||||||
|
or RendezvousErrorCode.Expired
|
||||||
|
or RendezvousErrorCode.Forbidden
|
||||||
|
or RendezvousErrorCode.AuthenticationRequired)
|
||||||
|
{
|
||||||
|
LeaseLost?.Invoke(this, EventArgs.Empty);
|
||||||
|
return new(LeaseMaintenanceStopReason.LeaseLost, renewed.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(LeaseMaintenanceStopReason.Failed, renewed.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (linked.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return new(
|
||||||
|
_disposeCancellation.IsCancellationRequested
|
||||||
|
? LeaseMaintenanceStopReason.Disposed
|
||||||
|
: LeaseMaintenanceStopReason.Cancelled,
|
||||||
|
RendezvousErrorCode.None);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_activeRun = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class DirectConnectionRequest
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId { get; set; }
|
||||||
|
public string ConnectionTicket { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[DirectConnectionRequest {AttemptId}; ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DirectConnectionRequestCodec
|
||||||
|
{
|
||||||
|
public const int EncodedLength = 63;
|
||||||
|
|
||||||
|
private const int MagicLength = 4;
|
||||||
|
private const int AttemptIdLength = 16;
|
||||||
|
private const int TicketLength = ContractLimits.DerivedCredentialCharacters;
|
||||||
|
private static readonly byte[] Magic = [(byte)'R', (byte)'V', (byte)'D', (byte)'1'];
|
||||||
|
|
||||||
|
public static bool IsRendezvousRequest(ReadOnlySpan<byte> encoded) =>
|
||||||
|
encoded.Length >= MagicLength && encoded[..MagicLength].SequenceEqual(Magic);
|
||||||
|
|
||||||
|
public static byte[] Encode(JoinAttemptId attemptId, string connectionTicket)
|
||||||
|
{
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| connectionTicket is null
|
||||||
|
|| connectionTicket.Length != TicketLength
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The direct connection request fields are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] encoded = new byte[EncodedLength];
|
||||||
|
Magic.CopyTo(encoded, 0);
|
||||||
|
if (!attemptId.Value.TryWriteBytes(encoded.AsSpan(MagicLength, AttemptIdLength)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Encoding.ASCII.GetBytes(
|
||||||
|
connectionTicket,
|
||||||
|
0,
|
||||||
|
connectionTicket.Length,
|
||||||
|
encoded,
|
||||||
|
MagicLength + AttemptIdLength);
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
out DirectConnectionRequest? request)
|
||||||
|
{
|
||||||
|
request = null;
|
||||||
|
if (encoded.Length != EncodedLength
|
||||||
|
|| !encoded[..MagicLength].SequenceEqual(Magic))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid attemptId = new(encoded.Slice(MagicLength, AttemptIdLength));
|
||||||
|
if (attemptId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string ticket = Encoding.ASCII.GetString(encoded[(MagicLength + AttemptIdLength)..]);
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(ticket))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
request = new DirectConnectionRequest
|
||||||
|
{
|
||||||
|
AttemptId = new JoinAttemptId(attemptId),
|
||||||
|
ConnectionTicket = ticket,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientCoordinator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly NetManager _manager;
|
||||||
|
private readonly RendezvousNetListener _networkEvents;
|
||||||
|
private readonly EventBasedNatPunchListener _punchEvents;
|
||||||
|
private readonly IPEndPoint _mediator;
|
||||||
|
private readonly CreateJoinAttemptResponse _attempt;
|
||||||
|
private readonly IRendezvousCoordinatorClock _clock;
|
||||||
|
private readonly RendezvousCoordinatorOptions _options;
|
||||||
|
private readonly RendezvousPunchRetrySchedule _retry;
|
||||||
|
private readonly object _completionGate = new();
|
||||||
|
private readonly TimeSpan _startedAt;
|
||||||
|
private readonly TimeSpan _attemptDeadline;
|
||||||
|
private readonly TimeSpan _punchDeadline;
|
||||||
|
private readonly NetworkEndpoint? _dedicatedFallback;
|
||||||
|
private NetPeer? _connectingPeer;
|
||||||
|
private IPEndPoint? _directEndpoint;
|
||||||
|
private TimeSpan? _directDeadline;
|
||||||
|
private RendezvousConnectionOutcome? _outcome;
|
||||||
|
private bool _cancelRequested;
|
||||||
|
private int _polling;
|
||||||
|
private bool _subscriptionsReleased;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
public RendezvousClientCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousCoordinatorOptions? options = null)
|
||||||
|
: this(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
mediator,
|
||||||
|
attempt,
|
||||||
|
options,
|
||||||
|
new SystemRendezvousCoordinatorClock())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousClientCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousCoordinatorOptions? options,
|
||||||
|
IRendezvousCoordinatorClock clock)
|
||||||
|
{
|
||||||
|
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||||
|
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
|
||||||
|
_punchEvents = _networkEvents.PunchEvents;
|
||||||
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
_attempt = attempt ?? throw new ArgumentNullException(nameof(attempt));
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
_options = (options ?? new RendezvousCoordinatorOptions())
|
||||||
|
.CopyAndValidate();
|
||||||
|
_retry = new(_options, _clock);
|
||||||
|
|
||||||
|
RendezvousManagerGuard.Validate(_manager, _networkEvents);
|
||||||
|
DateTimeOffset startedUtc = _clock.UtcNow;
|
||||||
|
if (_mediator.Port is < 1 or > 65_535
|
||||||
|
|| _attempt.AttemptId.Value == Guid.Empty
|
||||||
|
|| _attempt.MediationHandle.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability)
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest)
|
||||||
|
|| _attempt.ExpiresAt <= startedUtc)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The client traversal inputs are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_startedAt = _clock.Elapsed;
|
||||||
|
_attemptDeadline = _startedAt + (_attempt.ExpiresAt - startedUtc);
|
||||||
|
_punchDeadline = Min(_attemptDeadline, _startedAt + _options.PunchTimeout);
|
||||||
|
_dedicatedFallback = RendezvousEndpoint.Copy(
|
||||||
|
_options.DedicatedFallbackOverride ?? _attempt.DedicatedFallback);
|
||||||
|
|
||||||
|
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
|
||||||
|
_networkEvents.RendezvousNetworkError += OnNetworkError;
|
||||||
|
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<RendezvousConnectionCompletedEventArgs>? Completed;
|
||||||
|
|
||||||
|
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
|
||||||
|
public NetPeer? ConnectedPeer { get; private set; }
|
||||||
|
public RendezvousConnectionOutcome? Outcome => Volatile.Read(ref _outcome);
|
||||||
|
public bool IsCompleted => Outcome is not null;
|
||||||
|
|
||||||
|
public void Cancel() => Volatile.Write(ref _cancelRequested, true);
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (joinClient is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(joinClient));
|
||||||
|
}
|
||||||
|
|
||||||
|
ThrowIfDisposed();
|
||||||
|
Cancel();
|
||||||
|
return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (joinClient is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(joinClient));
|
||||||
|
}
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (Outcome is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The connection attempt has not completed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinClient.ReportOutcomeAsync(_attempt, Outcome, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Poll()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Interlocked.Exchange(ref _polling, 1) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _cancelRequested))
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Cancelled,
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.Caller,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
CurrentPhase());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
CompleteManagerStopped();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.PollEvents();
|
||||||
|
_manager.NatPunchModule.PollEvents();
|
||||||
|
if (IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
TimeSpan elapsed = _clock.Elapsed;
|
||||||
|
if (Volatile.Read(ref _cancelRequested))
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Cancelled,
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.Caller,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
CurrentPhase());
|
||||||
|
}
|
||||||
|
else if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
CompleteManagerStopped();
|
||||||
|
}
|
||||||
|
else if (now >= _attempt.ExpiresAt || elapsed >= _attemptDeadline)
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
ConnectionOutcomeKind.AttemptExpired,
|
||||||
|
RendezvousConnectionOutcomeSource.RendezvousService,
|
||||||
|
RendezvousConnectionFailureCategory.Authorization,
|
||||||
|
RendezvousConnectionPhase.Authorization);
|
||||||
|
}
|
||||||
|
else if (State == RendezvousConnectionState.Punching)
|
||||||
|
{
|
||||||
|
if (elapsed >= _punchDeadline
|
||||||
|
|| _retry.IsExhausted && _retry.IsDue(elapsed))
|
||||||
|
{
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.NatTraversal,
|
||||||
|
RendezvousConnectionPhase.NatTraversal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_retry.IsDue(elapsed))
|
||||||
|
{
|
||||||
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
_mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Client,
|
||||||
|
_attempt.MediationHandle,
|
||||||
|
_attempt.ClientPunchCapability));
|
||||||
|
_retry.RecordRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (State == RendezvousConnectionState.Connecting
|
||||||
|
&& _directDeadline is TimeSpan directDeadline
|
||||||
|
&& directDeadline <= elapsed)
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
ConnectionOutcomeKind.DirectConnectTimedOut,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _polling, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsCompleted)
|
||||||
|
{
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Disposed,
|
||||||
|
ConnectionOutcomeKind.Disposed,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
CurrentPhase());
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseSubscriptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[RendezvousClientCoordinator {_attempt.AttemptId}; credentials redacted]";
|
||||||
|
|
||||||
|
private void OnNatIntroductionSuccess(
|
||||||
|
IPEndPoint target,
|
||||||
|
NatAddressType addressType,
|
||||||
|
string encodedIntroduction)
|
||||||
|
{
|
||||||
|
_ = addressType;
|
||||||
|
if (State != RendezvousConnectionState.Punching
|
||||||
|
|| !NatIntroductionTokenCodec.TryDecode(
|
||||||
|
encodedIntroduction,
|
||||||
|
out NatIntroductionToken? introduction)
|
||||||
|
|| introduction is null
|
||||||
|
|| introduction.AttemptId != _attempt.AttemptId
|
||||||
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
_attempt.ConnectionTicketDigest))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] connectionData = DirectConnectionRequestCodec.Encode(
|
||||||
|
introduction.AttemptId,
|
||||||
|
introduction.ConnectionTicket);
|
||||||
|
_directEndpoint = target;
|
||||||
|
_connectingPeer = _manager.Connect(target, connectionData);
|
||||||
|
if (_connectingPeer is null
|
||||||
|
|| _connectingPeer.ConnectionState != ConnectionState.Outgoing)
|
||||||
|
{
|
||||||
|
_connectingPeer = null;
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
ConnectionOutcomeKind.TransportError,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State = RendezvousConnectionState.Connecting;
|
||||||
|
_directDeadline = Min(
|
||||||
|
_attemptDeadline,
|
||||||
|
_clock.Elapsed + _options.DirectConnectTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerConnected(NetPeer peer)
|
||||||
|
{
|
||||||
|
if (State != RendezvousConnectionState.Connecting
|
||||||
|
|| !ReferenceEquals(peer, _connectingPeer))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Connected,
|
||||||
|
ConnectionOutcomeKind.Connected,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.None,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||||
|
{
|
||||||
|
if (State == RendezvousConnectionState.Connecting
|
||||||
|
&& ReferenceEquals(peer, _connectingPeer))
|
||||||
|
{
|
||||||
|
ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
|
||||||
|
? ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
: disconnectInfo.Reason == DisconnectReason.ConnectionFailed
|
||||||
|
? ConnectionOutcomeKind.TransportError
|
||||||
|
: ConnectionOutcomeKind.HostRejected;
|
||||||
|
Complete(
|
||||||
|
kind == ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
? RendezvousConnectionState.TimedOut
|
||||||
|
: RendezvousConnectionState.Rejected,
|
||||||
|
kind,
|
||||||
|
kind == ConnectionOutcomeKind.HostRejected
|
||||||
|
? RendezvousConnectionOutcomeSource.RemoteHost
|
||||||
|
: RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
|
||||||
|
{
|
||||||
|
_ = socketError;
|
||||||
|
if (State == RendezvousConnectionState.Punching && endpoint.Equals(_mediator))
|
||||||
|
{
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
ConnectionOutcomeKind.MediatorUnavailable,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.Mediation,
|
||||||
|
RendezvousConnectionPhase.Mediation);
|
||||||
|
}
|
||||||
|
else if (State == RendezvousConnectionState.Connecting
|
||||||
|
&& endpoint.Equals(_directEndpoint))
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
ConnectionOutcomeKind.TransportError,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisconnectPendingPeer()
|
||||||
|
{
|
||||||
|
if (_connectingPeer is not null && State == RendezvousConnectionState.Connecting)
|
||||||
|
{
|
||||||
|
_connectingPeer.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Complete(
|
||||||
|
RendezvousConnectionState terminalState,
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
NetPeer? peer = null)
|
||||||
|
{
|
||||||
|
RendezvousConnectionCompletedEventArgs completion;
|
||||||
|
lock (_completionGate)
|
||||||
|
{
|
||||||
|
if (_outcome is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
_clock.Elapsed - _startedAt,
|
||||||
|
ShouldOfferFallback(kind) ? _dedicatedFallback : null,
|
||||||
|
peer);
|
||||||
|
State = terminalState;
|
||||||
|
if (kind == ConnectionOutcomeKind.Connected)
|
||||||
|
{
|
||||||
|
ConnectedPeer = peer;
|
||||||
|
}
|
||||||
|
Volatile.Write(ref _outcome, outcome);
|
||||||
|
ReleaseSubscriptions();
|
||||||
|
completion = new(terminalState, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
Completed?.Invoke(this, completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseSubscriptions()
|
||||||
|
{
|
||||||
|
lock (_completionGate)
|
||||||
|
{
|
||||||
|
if (_subscriptionsReleased)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
|
||||||
|
_networkEvents.RendezvousNetworkError -= OnNetworkError;
|
||||||
|
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
|
||||||
|
_subscriptionsReleased = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteManagerStopped() => Complete(
|
||||||
|
RendezvousConnectionState.ManagerStopped,
|
||||||
|
ConnectionOutcomeKind.ManagerStopped,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
CurrentPhase());
|
||||||
|
|
||||||
|
private RendezvousConnectionPhase CurrentPhase() => State switch
|
||||||
|
{
|
||||||
|
RendezvousConnectionState.Punching => RendezvousConnectionPhase.NatTraversal,
|
||||||
|
RendezvousConnectionState.Connecting => RendezvousConnectionPhase.DirectConnection,
|
||||||
|
_ => RendezvousConnectionPhase.Complete,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool ShouldOfferFallback(ConnectionOutcomeKind kind) => kind is not (
|
||||||
|
ConnectionOutcomeKind.Connected
|
||||||
|
or ConnectionOutcomeKind.Cancelled
|
||||||
|
or ConnectionOutcomeKind.Disposed);
|
||||||
|
|
||||||
|
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
|
||||||
|
left <= right ? left : right;
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum RendezvousConnectionState
|
||||||
|
{
|
||||||
|
Punching = 1,
|
||||||
|
Connecting = 2,
|
||||||
|
Connected = 3,
|
||||||
|
Cancelled = 4,
|
||||||
|
TimedOut = 5,
|
||||||
|
Rejected = 6,
|
||||||
|
ManagerStopped = 7,
|
||||||
|
Disposed = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousConnectionCompletedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
[Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
|
||||||
|
public RendezvousConnectionCompletedEventArgs(
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
NetPeer? peer)
|
||||||
|
: this(state, RendezvousCompletionInvariant.FromLegacy(state, peer))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousConnectionCompletedEventArgs(
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
RendezvousConnectionOutcome outcome)
|
||||||
|
{
|
||||||
|
RendezvousCompletionInvariant.Validate(state, outcome);
|
||||||
|
State = state;
|
||||||
|
Outcome = outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RendezvousConnectionState State { get; }
|
||||||
|
public RendezvousConnectionOutcome Outcome { get; }
|
||||||
|
public NetPeer? Peer => Outcome.Peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RendezvousCompletionInvariant
|
||||||
|
{
|
||||||
|
internal static RendezvousConnectionOutcome FromLegacy(
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
NetPeer? peer) => state switch
|
||||||
|
{
|
||||||
|
RendezvousConnectionState.Connected when peer is not null => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.Connected,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.None,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
TimeSpan.Zero,
|
||||||
|
peer: peer),
|
||||||
|
RendezvousConnectionState.Cancelled => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.Caller,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.TimedOut => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.DirectConnectTimedOut,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.Rejected => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.HostRejected,
|
||||||
|
RendezvousConnectionOutcomeSource.RemoteHost,
|
||||||
|
RendezvousConnectionFailureCategory.Authorization,
|
||||||
|
RendezvousConnectionPhase.Authorization,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.ManagerStopped => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.ManagerStopped,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.Disposed => RendezvousConnectionOutcome.Create(
|
||||||
|
ConnectionOutcomeKind.Disposed,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
TimeSpan.Zero),
|
||||||
|
RendezvousConnectionState.Connected => throw new ArgumentNullException(
|
||||||
|
nameof(peer),
|
||||||
|
"A connected completion requires a peer."),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(state),
|
||||||
|
state,
|
||||||
|
"A completion event requires a terminal connection state."),
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static void Validate(
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
RendezvousConnectionOutcome outcome)
|
||||||
|
{
|
||||||
|
if (outcome is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(outcome));
|
||||||
|
}
|
||||||
|
if ((state == RendezvousConnectionState.Connected) != outcome.IsSuccess)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The connection state and typed outcome contradict each other.",
|
||||||
|
nameof(outcome));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
public int MaximumPunchRequests { get; set; } = 5;
|
||||||
|
public int MaximumAttemptChecksPerPoll { get; set; } = 128;
|
||||||
|
public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||||
|
public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||||
|
public TimeSpan PunchTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
public TimeSpan DirectConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20);
|
||||||
|
public double JitterRatio { get; set; } = 0.2;
|
||||||
|
public NetworkEndpoint? DedicatedFallbackOverride { get; set; }
|
||||||
|
|
||||||
|
internal RendezvousCoordinatorOptions CopyAndValidate()
|
||||||
|
{
|
||||||
|
if (MaximumPunchRequests is < 1 or > 20
|
||||||
|
|| MaximumAttemptChecksPerPoll is < 1 or > 1_024
|
||||||
|
|| InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10)
|
||||||
|
|| MaximumPunchRetryDelay < InitialPunchRetryDelay
|
||||||
|
|| MaximumPunchRetryDelay > TimeSpan.FromSeconds(10)
|
||||||
|
|| PunchTimeout <= TimeSpan.Zero
|
||||||
|
|| PunchTimeout > TimeSpan.FromSeconds(30)
|
||||||
|
|| DirectConnectTimeout <= TimeSpan.Zero
|
||||||
|
|| DirectConnectTimeout > TimeSpan.FromSeconds(30)
|
||||||
|
|| ConnectionTicketLifetime <= TimeSpan.Zero
|
||||||
|
|| ConnectionTicketLifetime > TimeSpan.FromSeconds(20)
|
||||||
|
|| JitterRatio is < 0 or > 1
|
||||||
|
|| DedicatedFallbackOverride is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(DedicatedFallbackOverride))
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
MaximumPunchRequests = MaximumPunchRequests,
|
||||||
|
MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll,
|
||||||
|
InitialPunchRetryDelay = InitialPunchRetryDelay,
|
||||||
|
MaximumPunchRetryDelay = MaximumPunchRetryDelay,
|
||||||
|
PunchTimeout = PunchTimeout,
|
||||||
|
DirectConnectTimeout = DirectConnectTimeout,
|
||||||
|
ConnectionTicketLifetime = ConnectionTicketLifetime,
|
||||||
|
JitterRatio = JitterRatio,
|
||||||
|
DedicatedFallbackOverride = RendezvousEndpoint.Copy(DedicatedFallbackOverride),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IRendezvousCoordinatorClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
TimeSpan Elapsed { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock
|
||||||
|
{
|
||||||
|
private readonly long _origin = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
|
public TimeSpan Elapsed => TimeSpan.FromSeconds(
|
||||||
|
(Stopwatch.GetTimestamp() - _origin) / (double)Stopwatch.Frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RendezvousManagerGuard
|
||||||
|
{
|
||||||
|
internal static void Validate(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents)
|
||||||
|
{
|
||||||
|
networkEvents.ValidateManager(manager);
|
||||||
|
if (!manager.IsRunning)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The caller-owned LiteNetLib manager must be running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manager.NatPunchEnabled
|
||||||
|
|| manager.UnsyncedEvents
|
||||||
|
|| manager.NatPunchModule.UnsyncedEvents)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The caller-owned manager must enable NAT punching and synchronized event dispatch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RendezvousPunchRetrySchedule(
|
||||||
|
RendezvousCoordinatorOptions options,
|
||||||
|
IRendezvousCoordinatorClock clock)
|
||||||
|
{
|
||||||
|
public int RequestsSent { get; private set; }
|
||||||
|
public TimeSpan NextRequestAt { get; private set; } = TimeSpan.Zero;
|
||||||
|
|
||||||
|
public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests;
|
||||||
|
|
||||||
|
public bool IsDue(TimeSpan elapsed) => elapsed >= NextRequestAt;
|
||||||
|
|
||||||
|
public void RecordRequest()
|
||||||
|
{
|
||||||
|
int exponent = Math.Min(RequestsSent, 30);
|
||||||
|
RequestsSent++;
|
||||||
|
double milliseconds = Math.Min(
|
||||||
|
options.InitialPunchRetryDelay.TotalMilliseconds * Math.Pow(2, exponent),
|
||||||
|
options.MaximumPunchRetryDelay.TotalMilliseconds);
|
||||||
|
if (options.JitterRatio > 0)
|
||||||
|
{
|
||||||
|
Span<byte> random = stackalloc byte[1];
|
||||||
|
RandomNumberGenerator.Fill(random);
|
||||||
|
double unit = random[0] / 255d;
|
||||||
|
double multiplier = 1 - options.JitterRatio + (2 * options.JitterRatio * unit);
|
||||||
|
milliseconds = Math.Min(
|
||||||
|
milliseconds * multiplier,
|
||||||
|
options.MaximumPunchRetryDelay.TotalMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
NextRequestAt = clock.Elapsed + TimeSpan.FromMilliseconds(milliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum RendezvousHostState
|
||||||
|
{
|
||||||
|
Active = 1,
|
||||||
|
ManagerStopped = 2,
|
||||||
|
Disposed = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousHostAttemptCompletedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
[Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
|
||||||
|
public RendezvousHostAttemptCompletedEventArgs(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
NetPeer? peer)
|
||||||
|
: this(attemptId, state, RendezvousCompletionInvariant.FromLegacy(state, peer))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousHostAttemptCompletedEventArgs(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
RendezvousConnectionOutcome outcome)
|
||||||
|
{
|
||||||
|
if (attemptId.Value == Guid.Empty)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The completed attempt ID is invalid.", nameof(attemptId));
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousCompletionInvariant.Validate(state, outcome);
|
||||||
|
AttemptId = attemptId;
|
||||||
|
State = state;
|
||||||
|
Outcome = outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptId AttemptId { get; }
|
||||||
|
public RendezvousConnectionState State { get; }
|
||||||
|
public RendezvousConnectionOutcome Outcome { get; }
|
||||||
|
public NetPeer? Peer => Outcome.Peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousHostCoordinator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly NetManager _manager;
|
||||||
|
private readonly RendezvousNetListener _networkEvents;
|
||||||
|
private readonly EventBasedNatPunchListener _punchEvents;
|
||||||
|
private readonly IPEndPoint _mediator;
|
||||||
|
private readonly PublishedSession _session;
|
||||||
|
private readonly IRendezvousJoinClient _joinClient;
|
||||||
|
private readonly RendezvousCoordinatorOptions _options;
|
||||||
|
private readonly IRendezvousCoordinatorClock _clock;
|
||||||
|
private readonly ConnectionTicketValidator _tickets;
|
||||||
|
private readonly Dictionary<JoinAttemptId, PendingHostAttempt> _attempts = [];
|
||||||
|
private readonly Dictionary<NetPeer, JoinAttemptId> _acceptedPeers = [];
|
||||||
|
private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = [];
|
||||||
|
private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = [];
|
||||||
|
private readonly Queue<JoinAttemptId> _attemptSchedule = [];
|
||||||
|
private readonly SortedDictionary<long, Queue<HostAttemptDeadline>> _deadlines = [];
|
||||||
|
private readonly List<JoinAttemptId> _cleanupScratch = [];
|
||||||
|
private HostJoinAttempt[]? _latestSnapshot;
|
||||||
|
private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue;
|
||||||
|
private DateTimeOffset _nextTerminalCleanupAt = DateTimeOffset.MinValue;
|
||||||
|
private int _refreshing;
|
||||||
|
private int _polling;
|
||||||
|
private bool _subscriptionsReleased;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
public RendezvousHostCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
PublishedSession session,
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
RendezvousCoordinatorOptions? options = null)
|
||||||
|
: this(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
mediator,
|
||||||
|
session,
|
||||||
|
joinClient,
|
||||||
|
options,
|
||||||
|
new SystemRendezvousCoordinatorClock(),
|
||||||
|
null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousHostCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
PublishedSession session,
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
RendezvousCoordinatorOptions? options,
|
||||||
|
IRendezvousCoordinatorClock clock,
|
||||||
|
ConnectionTicketValidator? tickets)
|
||||||
|
{
|
||||||
|
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||||
|
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
|
||||||
|
_punchEvents = _networkEvents.PunchEvents;
|
||||||
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||||
|
_joinClient = joinClient ?? throw new ArgumentNullException(nameof(joinClient));
|
||||||
|
_options = (options ?? new RendezvousCoordinatorOptions()).CopyAndValidate();
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
_tickets = tickets ?? new ConnectionTicketValidator();
|
||||||
|
|
||||||
|
RendezvousManagerGuard.Validate(_manager, _networkEvents);
|
||||||
|
ValidateInputs();
|
||||||
|
_networkEvents.RendezvousConnectionRequest += OnConnectionRequest;
|
||||||
|
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
|
||||||
|
_networkEvents.RendezvousNetworkError += OnNetworkError;
|
||||||
|
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<RendezvousHostAttemptCompletedEventArgs>? AttemptCompleted;
|
||||||
|
|
||||||
|
public RendezvousHostState State { get; private set; } = RendezvousHostState.Active;
|
||||||
|
public int PendingAttemptCount => _attempts.Count;
|
||||||
|
internal int DeferredRequestCount => _deferredRequests.Count;
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<int>> RefreshJoinAttemptsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (Interlocked.Exchange(ref _refreshing, 1) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("A host invitation refresh is already running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
|
||||||
|
await _joinClient.BrowseAllForHostAsync(
|
||||||
|
_session,
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<int>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
HostJoinAttempt[] snapshot = result.Value.Select(CopyAttempt).ToArray();
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Exchange(ref _latestSnapshot, snapshot);
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _latestSnapshot, null);
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Success(snapshot.Length);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _refreshing, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Poll()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Interlocked.Exchange(ref _polling, 1) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ApplySnapshots();
|
||||||
|
if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
Stop(
|
||||||
|
RendezvousHostState.ManagerStopped,
|
||||||
|
RendezvousConnectionState.ManagerStopped,
|
||||||
|
ConnectionOutcomeKind.ManagerStopped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.PollEvents();
|
||||||
|
_manager.PollEvents();
|
||||||
|
_manager.NatPunchModule.PollEvents();
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
TimeSpan elapsed = _clock.Elapsed;
|
||||||
|
if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
Stop(
|
||||||
|
RendezvousHostState.ManagerStopped,
|
||||||
|
RendezvousConnectionState.ManagerStopped,
|
||||||
|
ConnectionOutcomeKind.ManagerStopped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshPresence(now);
|
||||||
|
ProcessDueDeadlines(elapsed);
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int checks = Math.Min(
|
||||||
|
_attemptSchedule.Count,
|
||||||
|
_options.MaximumAttemptChecksPerPoll);
|
||||||
|
for (int index = 0; index < checks; index++)
|
||||||
|
{
|
||||||
|
JoinAttemptId attemptId = _attemptSchedule.Dequeue();
|
||||||
|
if (!_attempts.TryGetValue(attemptId, out PendingHostAttempt? attempt))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.State != RendezvousConnectionState.Punching)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.Retry.IsDue(elapsed))
|
||||||
|
{
|
||||||
|
if (attempt.Retry.IsExhausted)
|
||||||
|
{
|
||||||
|
CompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.NatTraversal,
|
||||||
|
RendezvousConnectionPhase.NatTraversal);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
_mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
attempt.Invitation.MediationHandle,
|
||||||
|
attempt.Invitation.HostPunchCapability));
|
||||||
|
attempt.Retry.RecordRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
_attemptSchedule.Enqueue(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= _nextTerminalCleanupAt)
|
||||||
|
{
|
||||||
|
_cleanupScratch.Clear();
|
||||||
|
foreach (KeyValuePair<JoinAttemptId, DateTimeOffset> terminal in _terminalAttempts)
|
||||||
|
{
|
||||||
|
if (terminal.Value <= now)
|
||||||
|
{
|
||||||
|
_cleanupScratch.Add(terminal.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JoinAttemptId attemptId in _cleanupScratch)
|
||||||
|
{
|
||||||
|
_terminalAttempts.Remove(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextTerminalCleanupAt = now + TimeSpan.FromSeconds(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _polling, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stop(
|
||||||
|
RendezvousHostState.Disposed,
|
||||||
|
RendezvousConnectionState.Disposed,
|
||||||
|
ConnectionOutcomeKind.Disposed);
|
||||||
|
Interlocked.Exchange(ref _latestSnapshot, null);
|
||||||
|
_attemptSchedule.Clear();
|
||||||
|
_deadlines.Clear();
|
||||||
|
_terminalAttempts.Clear();
|
||||||
|
_cleanupScratch.Clear();
|
||||||
|
_tickets.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[RendezvousHostCoordinator {_session.ListingId}; credentials redacted]";
|
||||||
|
|
||||||
|
private void ApplySnapshots()
|
||||||
|
{
|
||||||
|
HostJoinAttempt[]? latest = Interlocked.Exchange(ref _latestSnapshot, null);
|
||||||
|
|
||||||
|
if (latest is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
TimeSpan elapsed = _clock.Elapsed;
|
||||||
|
foreach (HostJoinAttempt invitation in latest)
|
||||||
|
{
|
||||||
|
if (invitation.AttemptId.Value == Guid.Empty
|
||||||
|
|| invitation.MediationHandle.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(invitation.HostPunchCapability)
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(
|
||||||
|
invitation.ConnectionTicketDigest))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.IsCancelled)
|
||||||
|
{
|
||||||
|
if (_attempts.ContainsKey(invitation.AttemptId))
|
||||||
|
{
|
||||||
|
CompleteAttempt(
|
||||||
|
invitation.AttemptId,
|
||||||
|
RendezvousConnectionState.Cancelled,
|
||||||
|
ConnectionOutcomeKind.Cancelled,
|
||||||
|
RendezvousConnectionOutcomeSource.RendezvousService,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
RendezvousConnectionPhase.Authorization);
|
||||||
|
}
|
||||||
|
|
||||||
|
_terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.ExpiresAt <= now
|
||||||
|
|| _attempts.ContainsKey(invitation.AttemptId)
|
||||||
|
|| _terminalAttempts.ContainsKey(invitation.AttemptId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan attemptDeadline = elapsed + (invitation.ExpiresAt - now);
|
||||||
|
TimeSpan punchDeadline = Min(
|
||||||
|
attemptDeadline,
|
||||||
|
elapsed + _options.PunchTimeout);
|
||||||
|
_attempts.Add(
|
||||||
|
invitation.AttemptId,
|
||||||
|
new PendingHostAttempt(
|
||||||
|
CopyAttempt(invitation),
|
||||||
|
new RendezvousPunchRetrySchedule(_options, _clock),
|
||||||
|
elapsed,
|
||||||
|
attemptDeadline,
|
||||||
|
punchDeadline));
|
||||||
|
EnqueueDeadline(
|
||||||
|
new HostAttemptDeadline(
|
||||||
|
invitation.AttemptId,
|
||||||
|
RendezvousConnectionState.Punching,
|
||||||
|
punchDeadline));
|
||||||
|
_attemptSchedule.Enqueue(invitation.AttemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshPresence(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (now < _nextPresenceAt || now >= _session.ExpiresAt)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
_mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.HostPresence,
|
||||||
|
_session.HostPresenceHandle,
|
||||||
|
_session.HostPresenceCapability));
|
||||||
|
_nextPresenceAt = now + TimeSpan.FromSeconds(_session.HostPresenceRefreshAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNatIntroductionSuccess(
|
||||||
|
IPEndPoint target,
|
||||||
|
NatAddressType addressType,
|
||||||
|
string encodedIntroduction)
|
||||||
|
{
|
||||||
|
_ = target;
|
||||||
|
_ = addressType;
|
||||||
|
if (!NatIntroductionTokenCodec.TryDecode(
|
||||||
|
encodedIntroduction,
|
||||||
|
out NatIntroductionToken? introduction)
|
||||||
|
|| introduction is null
|
||||||
|
|| !_attempts.TryGetValue(introduction.AttemptId, out PendingHostAttempt? attempt)
|
||||||
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
attempt.Invitation.ConnectionTicketDigest)
|
||||||
|
|| !_tickets.TryAuthorize(
|
||||||
|
introduction.AttemptId,
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
Min(
|
||||||
|
attempt.Invitation.ExpiresAt,
|
||||||
|
_clock.UtcNow + _options.ConnectionTicketLifetime)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt.State = RendezvousConnectionState.Connecting;
|
||||||
|
attempt.DirectDeadline = Min(
|
||||||
|
attempt.AttemptDeadline,
|
||||||
|
_clock.Elapsed + _options.DirectConnectTimeout);
|
||||||
|
EnqueueDeadline(new HostAttemptDeadline(
|
||||||
|
introduction.AttemptId,
|
||||||
|
RendezvousConnectionState.Connecting,
|
||||||
|
attempt.DirectDeadline.Value));
|
||||||
|
if (_deferredRequests.Remove(
|
||||||
|
introduction.AttemptId,
|
||||||
|
out DeferredConnectionRequest? deferred))
|
||||||
|
{
|
||||||
|
AcceptAuthorizedRequest(
|
||||||
|
introduction.AttemptId,
|
||||||
|
attempt,
|
||||||
|
deferred.Request,
|
||||||
|
deferred.ConnectionTicket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConnectionRequest(ConnectionRequest request)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<byte> data = request.Data.GetRemainingBytesSpan();
|
||||||
|
if (!DirectConnectionRequestCodec.IsRendezvousRequest(data))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DirectConnectionRequestCodec.TryDecode(data, out DirectConnectionRequest? connection)
|
||||||
|
|| connection is null
|
||||||
|
|| !_attempts.TryGetValue(connection.AttemptId, out PendingHostAttempt? attempt)
|
||||||
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||||
|
connection.ConnectionTicket,
|
||||||
|
attempt.Invitation.ConnectionTicketDigest))
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.State == RendezvousConnectionState.Punching)
|
||||||
|
{
|
||||||
|
_deferredRequests[connection.AttemptId] = new(
|
||||||
|
request,
|
||||||
|
connection.ConnectionTicket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.State != RendezvousConnectionState.Connecting)
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AcceptAuthorizedRequest(
|
||||||
|
connection.AttemptId,
|
||||||
|
attempt,
|
||||||
|
request,
|
||||||
|
connection.ConnectionTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerConnected(NetPeer peer)
|
||||||
|
{
|
||||||
|
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
CompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
RendezvousConnectionState.Connected,
|
||||||
|
ConnectionOutcomeKind.Connected,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.None,
|
||||||
|
RendezvousConnectionPhase.Complete,
|
||||||
|
peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||||
|
{
|
||||||
|
_ = disconnectInfo;
|
||||||
|
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
|
||||||
|
? ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
: ConnectionOutcomeKind.TransportError;
|
||||||
|
CompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
kind == ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
? RendezvousConnectionState.TimedOut
|
||||||
|
: RendezvousConnectionState.Rejected,
|
||||||
|
kind,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
RendezvousConnectionPhase.DirectConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
|
||||||
|
{
|
||||||
|
_ = socketError;
|
||||||
|
if (!endpoint.Equals(_mediator))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JoinAttemptId attemptId in _attempts
|
||||||
|
.Where(static item => item.Value.State == RendezvousConnectionState.Punching)
|
||||||
|
.Select(static item => item.Key)
|
||||||
|
.ToArray())
|
||||||
|
{
|
||||||
|
CompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
RendezvousConnectionState.Rejected,
|
||||||
|
ConnectionOutcomeKind.MediatorUnavailable,
|
||||||
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
RendezvousConnectionFailureCategory.Mediation,
|
||||||
|
RendezvousConnectionPhase.Mediation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteAttempt(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
NetPeer? peer = null)
|
||||||
|
{
|
||||||
|
if (TryCompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
state,
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
peer,
|
||||||
|
out RendezvousHostAttemptCompletedEventArgs? completion))
|
||||||
|
{
|
||||||
|
AttemptCompleted?.Invoke(this, completion!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryCompleteAttempt(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
ConnectionOutcomeKind kind,
|
||||||
|
RendezvousConnectionOutcomeSource source,
|
||||||
|
RendezvousConnectionFailureCategory category,
|
||||||
|
RendezvousConnectionPhase phase,
|
||||||
|
NetPeer? peer,
|
||||||
|
out RendezvousHostAttemptCompletedEventArgs? completion)
|
||||||
|
{
|
||||||
|
completion = null;
|
||||||
|
if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.AcceptedPeer is not null)
|
||||||
|
{
|
||||||
|
_acceptedPeers.Remove(attempt.AcceptedPeer);
|
||||||
|
if (kind != ConnectionOutcomeKind.Connected)
|
||||||
|
{
|
||||||
|
attempt.AcceptedPeer.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_deferredRequests.Remove(attemptId, out DeferredConnectionRequest? deferred))
|
||||||
|
{
|
||||||
|
deferred.Request.RejectForce([]);
|
||||||
|
}
|
||||||
|
_tickets.Revoke(attemptId);
|
||||||
|
_terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt;
|
||||||
|
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
category,
|
||||||
|
phase,
|
||||||
|
_clock.Elapsed - attempt.StartedAt,
|
||||||
|
peer: peer);
|
||||||
|
completion = new(attemptId, state, outcome);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Stop(
|
||||||
|
RendezvousHostState hostState,
|
||||||
|
RendezvousConnectionState attemptState,
|
||||||
|
ConnectionOutcomeKind outcomeKind)
|
||||||
|
{
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State = hostState;
|
||||||
|
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
|
||||||
|
foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray())
|
||||||
|
{
|
||||||
|
RendezvousConnectionPhase phase = _attempts[attemptId].State
|
||||||
|
== RendezvousConnectionState.Connecting
|
||||||
|
? RendezvousConnectionPhase.DirectConnection
|
||||||
|
: RendezvousConnectionPhase.NatTraversal;
|
||||||
|
if (TryCompleteAttempt(
|
||||||
|
attemptId,
|
||||||
|
attemptState,
|
||||||
|
outcomeKind,
|
||||||
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
||||||
|
RendezvousConnectionFailureCategory.Lifecycle,
|
||||||
|
phase,
|
||||||
|
null,
|
||||||
|
out RendezvousHostAttemptCompletedEventArgs? completion))
|
||||||
|
{
|
||||||
|
completions.Add(completion!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseSubscriptions();
|
||||||
|
foreach (RendezvousHostAttemptCompletedEventArgs completion in completions)
|
||||||
|
{
|
||||||
|
AttemptCompleted?.Invoke(this, completion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseSubscriptions()
|
||||||
|
{
|
||||||
|
if (_subscriptionsReleased)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_networkEvents.RendezvousConnectionRequest -= OnConnectionRequest;
|
||||||
|
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
|
||||||
|
_networkEvents.RendezvousNetworkError -= OnNetworkError;
|
||||||
|
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
|
||||||
|
_subscriptionsReleased = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateInputs()
|
||||||
|
{
|
||||||
|
if (_mediator.Port is < 1 or > 65_535
|
||||||
|
|| _session.HostPresenceHandle.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(_session.HostPresenceCapability)
|
||||||
|
|| _session.HostPresenceRefreshAfterSeconds < 1
|
||||||
|
|| _session.ExpiresAt <= _clock.UtcNow)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The host traversal inputs are invalid.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HostJoinAttempt CopyAttempt(HostJoinAttempt attempt) => new()
|
||||||
|
{
|
||||||
|
AttemptId = attempt.AttemptId,
|
||||||
|
MediationHandle = attempt.MediationHandle,
|
||||||
|
HostPunchCapability = attempt.HostPunchCapability,
|
||||||
|
ConnectionTicketDigest = attempt.ConnectionTicketDigest,
|
||||||
|
IsCancelled = attempt.IsCancelled,
|
||||||
|
ExpiresAt = attempt.ExpiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
|
||||||
|
left <= right ? left : right;
|
||||||
|
|
||||||
|
private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) =>
|
||||||
|
left <= right ? left : right;
|
||||||
|
|
||||||
|
private void EnqueueDeadline(HostAttemptDeadline deadline)
|
||||||
|
{
|
||||||
|
if (!_deadlines.TryGetValue(deadline.Deadline.Ticks, out Queue<HostAttemptDeadline>? bucket))
|
||||||
|
{
|
||||||
|
bucket = new Queue<HostAttemptDeadline>();
|
||||||
|
_deadlines.Add(deadline.Deadline.Ticks, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.Enqueue(deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessDueDeadlines(TimeSpan elapsed)
|
||||||
|
{
|
||||||
|
while (_deadlines.Count > 0)
|
||||||
|
{
|
||||||
|
KeyValuePair<long, Queue<HostAttemptDeadline>> first = _deadlines.First();
|
||||||
|
if (first.Key > elapsed.Ticks)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HostAttemptDeadline deadline = first.Value.Dequeue();
|
||||||
|
if (first.Value.Count == 0)
|
||||||
|
{
|
||||||
|
_deadlines.Remove(first.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_attempts.TryGetValue(deadline.AttemptId, out PendingHostAttempt? attempt)
|
||||||
|
|| attempt.State != deadline.ExpectedState
|
||||||
|
|| (deadline.ExpectedState == RendezvousConnectionState.Punching
|
||||||
|
? attempt.PunchDeadline
|
||||||
|
: attempt.DirectDeadline) != deadline.Deadline)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool expired = elapsed >= attempt.AttemptDeadline;
|
||||||
|
CompleteAttempt(
|
||||||
|
deadline.AttemptId,
|
||||||
|
RendezvousConnectionState.TimedOut,
|
||||||
|
expired
|
||||||
|
? ConnectionOutcomeKind.AttemptExpired
|
||||||
|
: deadline.ExpectedState == RendezvousConnectionState.Punching
|
||||||
|
? ConnectionOutcomeKind.PunchTimedOut
|
||||||
|
: ConnectionOutcomeKind.DirectConnectTimedOut,
|
||||||
|
expired
|
||||||
|
? RendezvousConnectionOutcomeSource.RendezvousService
|
||||||
|
: RendezvousConnectionOutcomeSource.LocalTraversal,
|
||||||
|
expired
|
||||||
|
? RendezvousConnectionFailureCategory.Authorization
|
||||||
|
: deadline.ExpectedState == RendezvousConnectionState.Punching
|
||||||
|
? RendezvousConnectionFailureCategory.NatTraversal
|
||||||
|
: RendezvousConnectionFailureCategory.DirectConnection,
|
||||||
|
expired
|
||||||
|
? RendezvousConnectionPhase.Authorization
|
||||||
|
: deadline.ExpectedState == RendezvousConnectionState.Punching
|
||||||
|
? RendezvousConnectionPhase.NatTraversal
|
||||||
|
: RendezvousConnectionPhase.DirectConnection);
|
||||||
|
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AcceptAuthorizedRequest(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
PendingHostAttempt attempt,
|
||||||
|
ConnectionRequest request,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
ConnectionTicketConsumptionResult consumption = _tickets.Consume(
|
||||||
|
attemptId,
|
||||||
|
connectionTicket);
|
||||||
|
if (consumption != ConnectionTicketConsumptionResult.Accepted)
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NetPeer peer = request.Accept();
|
||||||
|
attempt.AcceptedPeer = peer;
|
||||||
|
_acceptedPeers[peer] = attemptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PendingHostAttempt(
|
||||||
|
HostJoinAttempt invitation,
|
||||||
|
RendezvousPunchRetrySchedule retry,
|
||||||
|
TimeSpan startedAt,
|
||||||
|
TimeSpan attemptDeadline,
|
||||||
|
TimeSpan punchDeadline)
|
||||||
|
{
|
||||||
|
internal HostJoinAttempt Invitation { get; } = invitation;
|
||||||
|
internal RendezvousPunchRetrySchedule Retry { get; } = retry;
|
||||||
|
internal TimeSpan StartedAt { get; } = startedAt;
|
||||||
|
internal TimeSpan AttemptDeadline { get; } = attemptDeadline;
|
||||||
|
internal TimeSpan PunchDeadline { get; } = punchDeadline;
|
||||||
|
internal TimeSpan? DirectDeadline { get; set; }
|
||||||
|
internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching;
|
||||||
|
internal NetPeer? AcceptedPeer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HostAttemptDeadline(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState expectedState,
|
||||||
|
TimeSpan deadline)
|
||||||
|
{
|
||||||
|
internal JoinAttemptId AttemptId { get; } = attemptId;
|
||||||
|
internal RendezvousConnectionState ExpectedState { get; } = expectedState;
|
||||||
|
internal TimeSpan Deadline { get; } = deadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DeferredConnectionRequest(
|
||||||
|
ConnectionRequest request,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
internal ConnectionRequest Request { get; } = request;
|
||||||
|
internal string ConnectionTicket { get; } = connectionTicket;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using LiteNetLib;
|
||||||
|
using LiteNetLib.Utils;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousNetListener : INetEventListener
|
||||||
|
{
|
||||||
|
private NetManager? _manager;
|
||||||
|
|
||||||
|
public EventBasedNetListener GameplayEvents { get; } = new();
|
||||||
|
public EventBasedNatPunchListener PunchEvents { get; } = new();
|
||||||
|
|
||||||
|
public NetManager CreateManager()
|
||||||
|
{
|
||||||
|
if (_manager is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"This Rendezvous listener is already bound to a LiteNetLib manager.");
|
||||||
|
}
|
||||||
|
|
||||||
|
NetManager manager = new(this) { NatPunchEnabled = true };
|
||||||
|
manager.NatPunchModule.Init(PunchEvents);
|
||||||
|
_manager = manager;
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal event Action<NetPeer>? RendezvousPeerConnected;
|
||||||
|
internal event Action<NetPeer, DisconnectInfo>? RendezvousPeerDisconnected;
|
||||||
|
internal event Action<ConnectionRequest>? RendezvousConnectionRequest;
|
||||||
|
internal event Action<IPEndPoint, SocketError>? RendezvousNetworkError;
|
||||||
|
|
||||||
|
internal void ValidateManager(NetManager manager)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(_manager, manager))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The LiteNetLib manager must be created by this Rendezvous listener.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPeerConnected(NetPeer peer)
|
||||||
|
{
|
||||||
|
RendezvousPeerConnected?.Invoke(peer);
|
||||||
|
((INetEventListener)GameplayEvents).OnPeerConnected(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||||
|
{
|
||||||
|
RendezvousPeerDisconnected?.Invoke(peer, disconnectInfo);
|
||||||
|
((INetEventListener)GameplayEvents).OnPeerDisconnected(peer, disconnectInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnNetworkError(IPEndPoint endPoint, SocketError socketError)
|
||||||
|
{
|
||||||
|
RendezvousNetworkError?.Invoke(endPoint, socketError);
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkError(endPoint, socketError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnNetworkReceive(
|
||||||
|
NetPeer peer,
|
||||||
|
NetPacketReader reader,
|
||||||
|
byte channelNumber,
|
||||||
|
DeliveryMethod deliveryMethod) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkReceive(
|
||||||
|
peer,
|
||||||
|
reader,
|
||||||
|
channelNumber,
|
||||||
|
deliveryMethod);
|
||||||
|
|
||||||
|
public void OnNetworkReceiveUnconnected(
|
||||||
|
IPEndPoint remoteEndPoint,
|
||||||
|
NetPacketReader reader,
|
||||||
|
UnconnectedMessageType messageType) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkReceiveUnconnected(
|
||||||
|
remoteEndPoint,
|
||||||
|
reader,
|
||||||
|
messageType);
|
||||||
|
|
||||||
|
public void OnNetworkLatencyUpdate(NetPeer peer, int latency) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkLatencyUpdate(peer, latency);
|
||||||
|
|
||||||
|
public void OnConnectionRequest(ConnectionRequest request)
|
||||||
|
{
|
||||||
|
int position = request.Data.Position;
|
||||||
|
bool isRendezvous = DirectConnectionRequestCodec.IsRendezvousRequest(
|
||||||
|
request.Data.GetRemainingBytesSpan());
|
||||||
|
request.Data.SetPosition(position);
|
||||||
|
if (!isRendezvous)
|
||||||
|
{
|
||||||
|
((INetEventListener)GameplayEvents).OnConnectionRequest(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Action<ConnectionRequest>? handler = RendezvousConnectionRequest;
|
||||||
|
if (handler is null)
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMessageDelivered(NetPeer peer, object userData) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnMessageDelivered(peer, userData);
|
||||||
|
|
||||||
|
public void OnNtpResponse(NtpPacket packet) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNtpResponse(packet);
|
||||||
|
|
||||||
|
public void OnPeerAddressChanged(NetPeer peer, IPEndPoint previousAddress) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnPeerAddressChanged(peer, previousAddress);
|
||||||
|
}
|
||||||
@@ -49,6 +49,27 @@ public enum ConnectionOutcomeKind
|
|||||||
HostRejected = 7,
|
HostRejected = 7,
|
||||||
TransportFailed = 8,
|
TransportFailed = 8,
|
||||||
FallbackOffered = 9,
|
FallbackOffered = 9,
|
||||||
|
DirectoryNotFound = 10,
|
||||||
|
AttemptExpired = 11,
|
||||||
|
Unauthorized = 12,
|
||||||
|
RateLimited = 13,
|
||||||
|
NoHostPresence = 14,
|
||||||
|
ServiceUnavailable = 15,
|
||||||
|
MediatorUnavailable = 16,
|
||||||
|
PunchTimedOut = 17,
|
||||||
|
DirectConnectTimedOut = 18,
|
||||||
|
TransportError = 19,
|
||||||
|
ManagerStopped = 20,
|
||||||
|
Disposed = 21,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ConnectionElapsedBucket
|
||||||
|
{
|
||||||
|
UnderOneSecond = 1,
|
||||||
|
OneToFiveSeconds = 2,
|
||||||
|
FiveToFifteenSeconds = 3,
|
||||||
|
FifteenToThirtySeconds = 4,
|
||||||
|
ThirtySecondsOrMore = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum UdpPresenceMessageType : byte
|
public enum UdpPresenceMessageType : byte
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public static class ContractLimits
|
|||||||
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
|
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
|
||||||
public const int UdpCapabilityMaxCharacters = 192;
|
public const int UdpCapabilityMaxCharacters = 192;
|
||||||
public const int ConnectionTicketMaxCharacters = 192;
|
public const int ConnectionTicketMaxCharacters = 192;
|
||||||
|
public const int DerivedCredentialCharacters = 43;
|
||||||
|
public const int NatPunchRequestTokenCharacters = 192;
|
||||||
public const int LiteNetLibNatTokenMaxCharacters = 256;
|
public const int LiteNetLibNatTokenMaxCharacters = 256;
|
||||||
public const int SessionCapacityMaxPlayers = 10_000;
|
public const int SessionCapacityMaxPlayers = 10_000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,30 @@ public static class ContractValidation
|
|||||||
public static bool IsDiagnosticCodeValid(string? value) =>
|
public static bool IsDiagnosticCodeValid(string? value) =>
|
||||||
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
|
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
|
||||||
|
|
||||||
|
public static bool IsReportableConnectionOutcome(ConnectionOutcomeKind outcome) => outcome is
|
||||||
|
ConnectionOutcomeKind.Connected
|
||||||
|
or ConnectionOutcomeKind.Cancelled
|
||||||
|
or ConnectionOutcomeKind.TimedOut
|
||||||
|
or ConnectionOutcomeKind.StaleHost
|
||||||
|
or ConnectionOutcomeKind.TransportFailed
|
||||||
|
or ConnectionOutcomeKind.FallbackOffered
|
||||||
|
or ConnectionOutcomeKind.AttemptExpired
|
||||||
|
or ConnectionOutcomeKind.NoHostPresence
|
||||||
|
or ConnectionOutcomeKind.MediatorUnavailable
|
||||||
|
or ConnectionOutcomeKind.PunchTimedOut
|
||||||
|
or ConnectionOutcomeKind.DirectConnectTimedOut
|
||||||
|
or ConnectionOutcomeKind.HostRejected
|
||||||
|
or ConnectionOutcomeKind.TransportError
|
||||||
|
or ConnectionOutcomeKind.ManagerStopped
|
||||||
|
or ConnectionOutcomeKind.Disposed;
|
||||||
|
|
||||||
public static bool IsBuildVersionValid(string? value) =>
|
public static bool IsBuildVersionValid(string? value) =>
|
||||||
IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
|
!string.IsNullOrWhiteSpace(value)
|
||||||
|
&& IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
|
||||||
|
|
||||||
public static bool IsDisplayNameValid(string? value) =>
|
public static bool IsDisplayNameValid(string? value) =>
|
||||||
IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
|
!string.IsNullOrWhiteSpace(value)
|
||||||
|
&& IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
|
||||||
|
|
||||||
public static bool IsOpaqueHttpCredentialValid(string? value) =>
|
public static bool IsOpaqueHttpCredentialValid(string? value) =>
|
||||||
value is not null
|
value is not null
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
public sealed class ReportConnectionOutcomeRequest
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public ConnectionOutcomeKind Outcome { get; set; }
|
||||||
|
|
||||||
|
public ConnectionElapsedBucket ElapsedBucket { get; set; }
|
||||||
|
|
||||||
|
[Obsolete("Use ElapsedBucket. Exact elapsed time is accepted only for v1 compatibility and is not retained.")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||||
|
public int ElapsedMilliseconds { get; set; }
|
||||||
|
|
||||||
|
[Obsolete("Diagnostic codes are accepted only for v1 compatibility and are not retained.")]
|
||||||
|
public string? DiagnosticCode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReportConnectionOutcomeResponse
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool Accepted { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool IsDuplicate { get; set; }
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ public sealed class CreateJoinAttemptResponse
|
|||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string ClientPunchCapability { get; set; } = string.Empty;
|
public string ClientPunchCapability { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string ConnectionTicketDigest { get; set; } = string.Empty;
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
public NetworkEndpoint? DedicatedFallback { get; set; }
|
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||||
@@ -53,6 +56,12 @@ public sealed class HostJoinAttempt
|
|||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string HostPunchCapability { get; set; } = string.Empty;
|
public string HostPunchCapability { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string ConnectionTicketDigest { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool IsCancelled { get; set; }
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -67,26 +76,3 @@ public sealed class BrowseHostJoinAttemptsResponse
|
|||||||
|
|
||||||
public string? NextCursor { get; set; }
|
public string? NextCursor { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ReportConnectionOutcomeRequest
|
|
||||||
{
|
|
||||||
[JsonRequired]
|
|
||||||
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
|
||||||
|
|
||||||
[JsonRequired]
|
|
||||||
public ConnectionOutcomeKind Outcome { get; set; }
|
|
||||||
|
|
||||||
[JsonRequired]
|
|
||||||
public int ElapsedMilliseconds { get; set; }
|
|
||||||
|
|
||||||
public string? DiagnosticCode { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ReportConnectionOutcomeResponse
|
|
||||||
{
|
|
||||||
[JsonRequired]
|
|
||||||
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
|
||||||
|
|
||||||
[JsonRequired]
|
|
||||||
public bool Accepted { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ public sealed class SessionListing
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RegisterSessionRequest
|
public sealed class RegisterSessionRequest
|
||||||
@@ -75,6 +77,8 @@ public sealed class RegisterSessionRequest
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RegisterSessionResponse
|
public sealed class RegisterSessionResponse
|
||||||
@@ -99,6 +103,12 @@ public sealed class RegisterSessionResponse
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public int LeaseRenewAfterSeconds { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public int HostPresenceRefreshAfterSeconds { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RenewLeaseRequest
|
public sealed class RenewLeaseRequest
|
||||||
@@ -117,6 +127,9 @@ public sealed class RenewLeaseResponse
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public int RenewAfterSeconds { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpdateSessionRequest
|
public sealed class UpdateSessionRequest
|
||||||
@@ -138,6 +151,8 @@ public sealed class UpdateSessionRequest
|
|||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DeleteSessionRequest
|
public sealed class DeleteSessionRequest
|
||||||
@@ -165,6 +180,8 @@ public sealed class BrowseSessionsRequest
|
|||||||
public RegionId? RegionId { get; set; }
|
public RegionId? RegionId { get; set; }
|
||||||
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
|
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
|
||||||
|
|
||||||
|
public bool ExcludeFull { get; set; }
|
||||||
|
|
||||||
public string? Cursor { get; set; }
|
public string? Cursor { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ public static class ContractJson
|
|||||||
|
|
||||||
options.AllowTrailingCommas = false;
|
options.AllowTrailingCommas = false;
|
||||||
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||||
options.MaxDepth = 8;
|
// Nine is the minimum that lets ASP.NET generate the nullable fallback
|
||||||
|
// OpenAPI schema; the 16 KiB HTTP body limit still bounds parser work.
|
||||||
|
options.MaxDepth = 9;
|
||||||
options.NumberHandling = JsonNumberHandling.Strict;
|
options.NumberHandling = JsonNumberHandling.Strict;
|
||||||
options.PropertyNameCaseInsensitive = false;
|
options.PropertyNameCaseInsensitive = false;
|
||||||
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
public sealed class NatIntroductionToken
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId { get; set; }
|
||||||
|
public string ConnectionTicket { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[NatIntroductionToken {AttemptId}; ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NatIntroductionTokenCodec
|
||||||
|
{
|
||||||
|
public const int EncodedLength = ContractLimits.DerivedCredentialCharacters;
|
||||||
|
|
||||||
|
private const int DecodedLength = 32;
|
||||||
|
private const int AttemptIdLength = 16;
|
||||||
|
private const int AuthenticatorLength = DecodedLength - AttemptIdLength;
|
||||||
|
|
||||||
|
public static string Encode(JoinAttemptId attemptId, string derivedAuthenticator)
|
||||||
|
{
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(derivedAuthenticator)
|
||||||
|
|| !TryDecodeBase64Url(derivedAuthenticator, out byte[]? authenticator)
|
||||||
|
|| authenticator.Length != DecodedLength)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The NAT introduction token fields are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] payload = new byte[DecodedLength];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!attemptId.Value.TryWriteBytes(payload.AsSpan(0, AttemptIdLength)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticator.AsSpan(0, AuthenticatorLength).CopyTo(payload.AsSpan(AttemptIdLength));
|
||||||
|
return EncodeBase64Url(payload);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(authenticator);
|
||||||
|
CryptographicOperations.ZeroMemory(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(string? encoded, out NatIntroductionToken? token)
|
||||||
|
{
|
||||||
|
token = null;
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(encoded)
|
||||||
|
|| !TryDecodeBase64Url(encoded!, out byte[]? payload)
|
||||||
|
|| payload.Length != DecodedLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Guid attemptId = new(payload.AsSpan(0, AttemptIdLength));
|
||||||
|
if (attemptId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = new NatIntroductionToken
|
||||||
|
{
|
||||||
|
AttemptId = new JoinAttemptId(attemptId),
|
||||||
|
ConnectionTicket = encoded!,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ComputeDigest(string connectionTicket)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The connection ticket is invalid.", nameof(connectionTicket));
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] encoded = Encoding.ASCII.GetBytes(connectionTicket);
|
||||||
|
byte[] digest;
|
||||||
|
using (SHA256 sha256 = SHA256.Create())
|
||||||
|
{
|
||||||
|
digest = sha256.ComputeHash(encoded);
|
||||||
|
}
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return EncodeBase64Url(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool MatchesDigest(string? connectionTicket, string? expectedDigest)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(connectionTicket)
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(expectedDigest))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] actual = Encoding.ASCII.GetBytes(ComputeDigest(connectionTicket!));
|
||||||
|
byte[] expected = Encoding.ASCII.GetBytes(expectedDigest!);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(actual);
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDecodeBase64Url(string? encoded, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
if (encoded is null || encoded.Length != EncodedLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(
|
||||||
|
encoded.Replace('-', '+').Replace('_', '/') + "=");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EncodeBase64Url(byte[] value) =>
|
||||||
|
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
public enum NatPunchPeerRole
|
||||||
|
{
|
||||||
|
HostPresence = 1,
|
||||||
|
Host = 2,
|
||||||
|
Client = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NatPunchRequestToken
|
||||||
|
{
|
||||||
|
public NatPunchPeerRole Role { get; set; }
|
||||||
|
public MediationHandle MediationHandle { get; set; }
|
||||||
|
public string Capability { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override string ToString() => "[NatPunchRequestToken: capability redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NatPunchRequestTokenCodec
|
||||||
|
{
|
||||||
|
public const int EncodedLength = ContractLimits.NatPunchRequestTokenCharacters;
|
||||||
|
|
||||||
|
private const string VersionPrefix = "rv1:";
|
||||||
|
private const int HandleLength = 32;
|
||||||
|
private const int CapabilityLength = ContractLimits.DerivedCredentialCharacters;
|
||||||
|
private const char Separator = ':';
|
||||||
|
private const char Padding = '.';
|
||||||
|
|
||||||
|
public static string Encode(
|
||||||
|
NatPunchPeerRole role,
|
||||||
|
MediationHandle mediationHandle,
|
||||||
|
string capability)
|
||||||
|
{
|
||||||
|
if (!TryGetRoleCode(role, out char roleCode)
|
||||||
|
|| mediationHandle.Value == Guid.Empty
|
||||||
|
|| capability is null
|
||||||
|
|| capability.Length != CapabilityLength
|
||||||
|
|| !ContractValidation.IsCapabilityValid(capability))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The NAT punch request token fields are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string payload = string.Concat(
|
||||||
|
VersionPrefix,
|
||||||
|
roleCode,
|
||||||
|
Separator,
|
||||||
|
mediationHandle.Value.ToString("N"),
|
||||||
|
Separator,
|
||||||
|
capability);
|
||||||
|
return payload.PadRight(EncodedLength, Padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(string? encoded, out NatPunchRequestToken? token)
|
||||||
|
{
|
||||||
|
token = null;
|
||||||
|
if (encoded is null
|
||||||
|
|| encoded.Length != EncodedLength
|
||||||
|
|| !encoded.StartsWith(VersionPrefix, StringComparison.Ordinal)
|
||||||
|
|| !TryParseRole(encoded[VersionPrefix.Length], out NatPunchPeerRole role))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int roleSeparator = VersionPrefix.Length + 1;
|
||||||
|
int handleOffset = roleSeparator + 1;
|
||||||
|
int capabilitySeparator = handleOffset + HandleLength;
|
||||||
|
int capabilityOffset = capabilitySeparator + 1;
|
||||||
|
int paddingOffset = capabilityOffset + CapabilityLength;
|
||||||
|
string handleText = encoded.Substring(handleOffset, HandleLength);
|
||||||
|
if (encoded[roleSeparator] != Separator
|
||||||
|
|| encoded[capabilitySeparator] != Separator
|
||||||
|
|| !Guid.TryParseExact(handleText, "N", out Guid handle)
|
||||||
|
|| handle == Guid.Empty
|
||||||
|
|| !string.Equals(handleText, handle.ToString("N"), StringComparison.Ordinal)
|
||||||
|
|| !ContainsOnlyPadding(encoded, paddingOffset))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string capability = encoded.Substring(capabilityOffset, CapabilityLength);
|
||||||
|
if (!ContractValidation.IsCapabilityValid(capability))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = new NatPunchRequestToken
|
||||||
|
{
|
||||||
|
Role = role,
|
||||||
|
MediationHandle = new MediationHandle(handle),
|
||||||
|
Capability = capability,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetRoleCode(NatPunchPeerRole role, out char code)
|
||||||
|
{
|
||||||
|
code = role switch
|
||||||
|
{
|
||||||
|
NatPunchPeerRole.HostPresence => 'p',
|
||||||
|
NatPunchPeerRole.Host => 'h',
|
||||||
|
NatPunchPeerRole.Client => 'c',
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
return code != default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseRole(char code, out NatPunchPeerRole role)
|
||||||
|
{
|
||||||
|
role = code switch
|
||||||
|
{
|
||||||
|
'p' => NatPunchPeerRole.HostPresence,
|
||||||
|
'h' => NatPunchPeerRole.Host,
|
||||||
|
'c' => NatPunchPeerRole.Client,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
return role != default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsOnlyPadding(string value, int offset)
|
||||||
|
{
|
||||||
|
for (int index = offset; index < value.Length; index++)
|
||||||
|
{
|
||||||
|
if (value[index] != Padding)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
|
||||||
|
internal sealed class AbuseProtectionOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Rendezvous:AbuseProtection";
|
||||||
|
|
||||||
|
[Range(1, 60)]
|
||||||
|
public int WindowSeconds { get; set; } = 1;
|
||||||
|
|
||||||
|
[Range(1_000, 1_000_000)]
|
||||||
|
public int MaxTrackedKeys { get; set; } = 100_000;
|
||||||
|
|
||||||
|
[Range(0, 100_000)]
|
||||||
|
public int CriticalTrackedKeyReserve { get; set; } = 2_048;
|
||||||
|
|
||||||
|
[Range(1_000, 999_999)]
|
||||||
|
public int UdpTrackedKeyLimit { get; set; } = 70_000;
|
||||||
|
|
||||||
|
public string[] TrustedProxyAddresses { get; set; } = [];
|
||||||
|
|
||||||
|
public string[] OperatorAllowedAddresses { get; set; } = [];
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HealthGlobalRequestsPerWindow { get; set; } = 1_000;
|
||||||
|
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int HealthGlobalConcurrency { get; set; } = 32;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HealthIpPrefixRequestsPerWindow { get; set; } = 120;
|
||||||
|
|
||||||
|
[Range(1, 1_000)]
|
||||||
|
public int HealthIpPrefixConcurrency { get; set; } = 8;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int OperatorGlobalRequestsPerWindow { get; set; } = 1_000;
|
||||||
|
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int OperatorGlobalConcurrency { get; set; } = 32;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int OperatorIpPrefixRequestsPerWindow { get; set; } = 120;
|
||||||
|
|
||||||
|
[Range(1, 1_000)]
|
||||||
|
public int OperatorIpPrefixConcurrency { get; set; } = 8;
|
||||||
|
|
||||||
|
[Range(1, 1_000_000)]
|
||||||
|
public int HttpGlobalRequestsPerWindow { get; set; } = 20_000;
|
||||||
|
|
||||||
|
[Range(1, 1_000_000)]
|
||||||
|
public int HttpOptionalRequestsPerWindow { get; set; } = 18_000;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HttpIpPrefixRequestsPerWindow { get; set; } = 500;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HttpOptionalIpPrefixRequestsPerWindow { get; set; } = 450;
|
||||||
|
|
||||||
|
[Range(1, 1_000_000)]
|
||||||
|
public int HttpOperationRequestsPerWindow { get; set; } = 5_000;
|
||||||
|
|
||||||
|
[Range(1, 1_000_000)]
|
||||||
|
public int HttpTenantRequestsPerWindow { get; set; } = 2_000;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HttpPrincipalRequestsPerWindow { get; set; } = 500;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HttpResourceRequestsPerWindow { get; set; } = 200;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HttpGlobalConcurrency { get; set; } = 1_024;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HttpOptionalConcurrency { get; set; } = 768;
|
||||||
|
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int HttpIpPrefixConcurrency { get; set; } = 64;
|
||||||
|
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int HttpOptionalIpPrefixConcurrency { get; set; } = 48;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HttpOperationConcurrency { get; set; } = 256;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int HttpTenantConcurrency { get; set; } = 256;
|
||||||
|
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int HttpPrincipalConcurrency { get; set; } = 32;
|
||||||
|
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int HttpResourceConcurrency { get; set; } = 16;
|
||||||
|
|
||||||
|
[Range(1, 10_000_000)]
|
||||||
|
public int UdpGlobalDatagramsPerWindow { get; set; } = 100_000;
|
||||||
|
|
||||||
|
[Range(1, 1_000_000)]
|
||||||
|
public int UdpIpPrefixDatagramsPerWindow { get; set; } = 2_000;
|
||||||
|
|
||||||
|
[Range(1, 10_000_000)]
|
||||||
|
public int UdpOperationDatagramsPerWindow { get; set; } = 50_000;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int UdpCapabilityDatagramsPerWindow { get; set; } = 120;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int UdpResourceDatagramsPerWindow { get; set; } = 240;
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
|
||||||
|
internal sealed class AbuseProtectionService
|
||||||
|
{
|
||||||
|
private readonly AbuseProtectionOptions _options;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly TrackerState _httpTracker;
|
||||||
|
private readonly TrackerState _udpTracker;
|
||||||
|
private readonly RendezvousTelemetry? _telemetry;
|
||||||
|
private readonly HashSet<string> _operatorAllowedAddresses;
|
||||||
|
|
||||||
|
public AbuseProtectionService(
|
||||||
|
IOptions<AbuseProtectionOptions> options,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
RendezvousTelemetry? telemetry = null)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_telemetry = telemetry;
|
||||||
|
_operatorAllowedAddresses = options.Value.OperatorAllowedAddresses
|
||||||
|
.Select(static value => IPAddress.TryParse(value, out IPAddress? address)
|
||||||
|
? NormalizeAddress(address).ToString()
|
||||||
|
: string.Empty)
|
||||||
|
.Where(static value => value.Length > 0)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||||
|
_httpTracker = new(now);
|
||||||
|
_udpTracker = new(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAcquireHttpIngress(
|
||||||
|
IPAddress? remoteAddress,
|
||||||
|
string operation,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out int retryAfterSeconds)
|
||||||
|
{
|
||||||
|
string prefix = GetNetworkPrefix(remoteAddress);
|
||||||
|
List<RateDimension> rates =
|
||||||
|
[
|
||||||
|
new("http:rate:global", _options.HttpGlobalRequestsPerWindow),
|
||||||
|
new($"http:rate:ip:{prefix}", _options.HttpIpPrefixRequestsPerWindow),
|
||||||
|
new($"http:rate:operation:{operation}", _options.HttpOperationRequestsPerWindow),
|
||||||
|
];
|
||||||
|
List<RateDimension> concurrency =
|
||||||
|
[
|
||||||
|
new("http:concurrency:global", _options.HttpGlobalConcurrency),
|
||||||
|
new($"http:concurrency:ip:{prefix}", _options.HttpIpPrefixConcurrency),
|
||||||
|
new($"http:concurrency:operation:{operation}", _options.HttpOperationConcurrency),
|
||||||
|
];
|
||||||
|
if (!IsLeaseCriticalOperation(operation))
|
||||||
|
{
|
||||||
|
rates.Add(new("http:rate:optional", _options.HttpOptionalRequestsPerWindow));
|
||||||
|
rates.Add(new($"http:rate:optional-ip:{prefix}",
|
||||||
|
_options.HttpOptionalIpPrefixRequestsPerWindow));
|
||||||
|
concurrency.Add(new("http:concurrency:optional", _options.HttpOptionalConcurrency));
|
||||||
|
concurrency.Add(new($"http:concurrency:optional-ip:{prefix}",
|
||||||
|
_options.HttpOptionalIpPrefixConcurrency));
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryAcquire(
|
||||||
|
[.. rates],
|
||||||
|
[.. concurrency],
|
||||||
|
TrackerDomain.Http,
|
||||||
|
IsLeaseCriticalOperation(operation),
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAcquireHealthIngress(
|
||||||
|
IPAddress? remoteAddress,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out int retryAfterSeconds)
|
||||||
|
{
|
||||||
|
string prefix = GetNetworkPrefix(remoteAddress);
|
||||||
|
RateDimension[] rates =
|
||||||
|
[
|
||||||
|
new("health:rate:global", _options.HealthGlobalRequestsPerWindow),
|
||||||
|
new($"health:rate:ip:{prefix}", _options.HealthIpPrefixRequestsPerWindow),
|
||||||
|
];
|
||||||
|
RateDimension[] concurrency =
|
||||||
|
[
|
||||||
|
new("health:concurrency:global", _options.HealthGlobalConcurrency),
|
||||||
|
new($"health:concurrency:ip:{prefix}", _options.HealthIpPrefixConcurrency),
|
||||||
|
];
|
||||||
|
return TryAcquire(
|
||||||
|
rates,
|
||||||
|
concurrency,
|
||||||
|
TrackerDomain.Http,
|
||||||
|
true,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsOperatorSourceAllowed(IPAddress? remoteAddress) =>
|
||||||
|
remoteAddress is not null
|
||||||
|
&& _operatorAllowedAddresses.Contains(NormalizeAddress(remoteAddress).ToString());
|
||||||
|
|
||||||
|
public bool TryAcquireOperatorIngress(
|
||||||
|
IPAddress? remoteAddress,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out int retryAfterSeconds)
|
||||||
|
{
|
||||||
|
string prefix = GetNetworkPrefix(remoteAddress);
|
||||||
|
RateDimension[] rates =
|
||||||
|
[
|
||||||
|
new("operator:rate:global", _options.OperatorGlobalRequestsPerWindow),
|
||||||
|
new($"operator:rate:ip:{prefix}", _options.OperatorIpPrefixRequestsPerWindow),
|
||||||
|
];
|
||||||
|
RateDimension[] concurrency =
|
||||||
|
[
|
||||||
|
new("operator:concurrency:global", _options.OperatorGlobalConcurrency),
|
||||||
|
new($"operator:concurrency:ip:{prefix}", _options.OperatorIpPrefixConcurrency),
|
||||||
|
];
|
||||||
|
return TryAcquire(
|
||||||
|
rates,
|
||||||
|
concurrency,
|
||||||
|
TrackerDomain.Http,
|
||||||
|
true,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAcquireHttpIdentity(
|
||||||
|
string operation,
|
||||||
|
string? tenant,
|
||||||
|
string? principal,
|
||||||
|
string? resource,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out int retryAfterSeconds) => TryAcquireHttpIdentity(
|
||||||
|
operation,
|
||||||
|
null,
|
||||||
|
tenant,
|
||||||
|
principal,
|
||||||
|
resource,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
|
||||||
|
public bool TryAcquireHttpIdentity(
|
||||||
|
string operation,
|
||||||
|
IPAddress? remoteAddress,
|
||||||
|
string? tenant,
|
||||||
|
string? principal,
|
||||||
|
string? resource,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out int retryAfterSeconds)
|
||||||
|
{
|
||||||
|
string sourcePrefix = GetNetworkPrefix(remoteAddress);
|
||||||
|
List<RateDimension> rates = [];
|
||||||
|
List<RateDimension> concurrency = [];
|
||||||
|
AddDimension(rates, concurrency, "tenant", tenant,
|
||||||
|
_options.HttpTenantRequestsPerWindow, _options.HttpTenantConcurrency);
|
||||||
|
AddDimension(rates, concurrency, "principal", principal,
|
||||||
|
_options.HttpPrincipalRequestsPerWindow, _options.HttpPrincipalConcurrency);
|
||||||
|
AddDimension(rates, concurrency, "resource", resource,
|
||||||
|
_options.HttpResourceRequestsPerWindow, _options.HttpResourceConcurrency);
|
||||||
|
return TryAcquire(
|
||||||
|
[.. rates],
|
||||||
|
[.. concurrency],
|
||||||
|
TrackerDomain.Http,
|
||||||
|
IsLeaseCriticalOperation(operation),
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
|
||||||
|
void AddDimension(
|
||||||
|
List<RateDimension> rateDimensions,
|
||||||
|
List<RateDimension> concurrencyDimensions,
|
||||||
|
string kind,
|
||||||
|
string? value,
|
||||||
|
int rateLimit,
|
||||||
|
int concurrencyLimit)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind == "resource")
|
||||||
|
{
|
||||||
|
string validationKey =
|
||||||
|
$"http:source-resource:{operation}:{sourcePrefix}:{value}";
|
||||||
|
rateDimensions.Add(new($"{validationKey}:rate", rateLimit));
|
||||||
|
concurrencyDimensions.Add(new($"{validationKey}:concurrency", concurrencyLimit));
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = kind == "resource"
|
||||||
|
? $"http:resource-scoped:{operation}:{tenant ?? string.Empty}|{principal ?? string.Empty}:{value}"
|
||||||
|
: $"http:{kind}:{operation}:{value}";
|
||||||
|
rateDimensions.Add(new($"{key}:rate", rateLimit));
|
||||||
|
concurrencyDimensions.Add(new($"{key}:concurrency", concurrencyLimit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAcceptUdpIngress(IPAddress? remoteAddress, string operation)
|
||||||
|
{
|
||||||
|
string prefix = GetNetworkPrefix(remoteAddress);
|
||||||
|
RateDimension[] rates =
|
||||||
|
[
|
||||||
|
new("udp:rate:global", _options.UdpGlobalDatagramsPerWindow),
|
||||||
|
new($"udp:rate:ip:{prefix}", _options.UdpIpPrefixDatagramsPerWindow),
|
||||||
|
new($"udp:rate:operation:{operation}", _options.UdpOperationDatagramsPerWindow),
|
||||||
|
];
|
||||||
|
return TryAcquire(
|
||||||
|
rates,
|
||||||
|
[],
|
||||||
|
TrackerDomain.Udp,
|
||||||
|
false,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out _)
|
||||||
|
&& DisposeAccepted(lease);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAcceptUdpIdentity(
|
||||||
|
string operation,
|
||||||
|
string capability,
|
||||||
|
string resource) => TryAcceptUdpIdentity(
|
||||||
|
operation,
|
||||||
|
null,
|
||||||
|
capability,
|
||||||
|
resource);
|
||||||
|
|
||||||
|
public bool TryAcceptUdpIdentity(
|
||||||
|
string operation,
|
||||||
|
IPAddress? remoteAddress,
|
||||||
|
string capability,
|
||||||
|
string resource)
|
||||||
|
{
|
||||||
|
string sourcePrefix = GetNetworkPrefix(remoteAddress);
|
||||||
|
string capabilityFingerprint = FingerprintSecret(capability);
|
||||||
|
RateDimension[] rates =
|
||||||
|
[
|
||||||
|
new($"udp:rate:capability:{operation}:{capabilityFingerprint}",
|
||||||
|
_options.UdpCapabilityDatagramsPerWindow),
|
||||||
|
new($"udp:rate:source-resource:{operation}:{sourcePrefix}:{resource}",
|
||||||
|
_options.UdpResourceDatagramsPerWindow),
|
||||||
|
new($"udp:rate:resource:{operation}:{capabilityFingerprint}:{resource}",
|
||||||
|
_options.UdpResourceDatagramsPerWindow),
|
||||||
|
];
|
||||||
|
return TryAcquire(
|
||||||
|
rates,
|
||||||
|
[],
|
||||||
|
TrackerDomain.Udp,
|
||||||
|
false,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out _)
|
||||||
|
&& DisposeAccepted(lease);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FingerprintSecret(string secret)
|
||||||
|
{
|
||||||
|
int byteCount = Encoding.UTF8.GetByteCount(secret);
|
||||||
|
byte[]? rented = null;
|
||||||
|
Span<byte> encoded = byteCount <= 1_024
|
||||||
|
? stackalloc byte[byteCount]
|
||||||
|
: (rented = ArrayPool<byte>.Shared.Rent(byteCount)).AsSpan(0, byteCount);
|
||||||
|
Span<byte> digest = stackalloc byte[32];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = Encoding.UTF8.GetBytes(secret, encoded);
|
||||||
|
_ = SHA256.HashData(encoded, digest);
|
||||||
|
return Convert.ToHexString(digest[..12]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
if (rented is not null)
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(rented);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal int TrackedKeyCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
int http;
|
||||||
|
int udp;
|
||||||
|
lock (_httpTracker.Gate)
|
||||||
|
{
|
||||||
|
http = _httpTracker.WindowCounts.Count + _httpTracker.ConcurrencyCounts.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_udpTracker.Gate)
|
||||||
|
{
|
||||||
|
udp = _udpTracker.WindowCounts.Count + _udpTracker.ConcurrencyCounts.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return http + udp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryAcquire(
|
||||||
|
ReadOnlySpan<RateDimension> rates,
|
||||||
|
ReadOnlySpan<RateDimension> concurrency,
|
||||||
|
TrackerDomain domain,
|
||||||
|
bool canUseCriticalReserve,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out int retryAfterSeconds)
|
||||||
|
{
|
||||||
|
TrackerState tracker = domain == TrackerDomain.Udp ? _udpTracker : _httpTracker;
|
||||||
|
bool accepted;
|
||||||
|
lock (tracker.Gate)
|
||||||
|
{
|
||||||
|
accepted = TryAcquireLocked(
|
||||||
|
tracker,
|
||||||
|
rates,
|
||||||
|
concurrency,
|
||||||
|
domain,
|
||||||
|
canUseCriticalReserve,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accepted)
|
||||||
|
{
|
||||||
|
_telemetry?.RecordLimiterDrop(
|
||||||
|
domain == TrackerDomain.Udp ? "udp" : "http",
|
||||||
|
"rate-or-concurrency");
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryAcquireLocked(
|
||||||
|
TrackerState tracker,
|
||||||
|
ReadOnlySpan<RateDimension> rates,
|
||||||
|
ReadOnlySpan<RateDimension> concurrency,
|
||||||
|
TrackerDomain domain,
|
||||||
|
bool canUseCriticalReserve,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out int retryAfterSeconds)
|
||||||
|
{
|
||||||
|
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||||
|
TimeSpan window = TimeSpan.FromSeconds(_options.WindowSeconds);
|
||||||
|
if (now - tracker.WindowStartedAt >= window || now < tracker.WindowStartedAt)
|
||||||
|
{
|
||||||
|
tracker.WindowCounts.Clear();
|
||||||
|
tracker.WindowStartedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAfterSeconds = Math.Max(
|
||||||
|
1,
|
||||||
|
(int)Math.Ceiling((window - (now - tracker.WindowStartedAt)).TotalSeconds));
|
||||||
|
int stagedNewKeys = 0;
|
||||||
|
int partitionLimit = domain == TrackerDomain.Udp
|
||||||
|
? _options.UdpTrackedKeyLimit
|
||||||
|
: _options.MaxTrackedKeys - _options.UdpTrackedKeyLimit;
|
||||||
|
int maxTrackedKeys = domain == TrackerDomain.Udp || canUseCriticalReserve
|
||||||
|
? partitionLimit
|
||||||
|
: partitionLimit - _options.CriticalTrackedKeyReserve;
|
||||||
|
if (!CanAcquireAll(
|
||||||
|
tracker,
|
||||||
|
tracker.WindowCounts,
|
||||||
|
rates,
|
||||||
|
maxTrackedKeys,
|
||||||
|
ref stagedNewKeys)
|
||||||
|
|| !CanAcquireAll(
|
||||||
|
tracker,
|
||||||
|
tracker.ConcurrencyCounts,
|
||||||
|
concurrency,
|
||||||
|
maxTrackedKeys,
|
||||||
|
ref stagedNewKeys))
|
||||||
|
{
|
||||||
|
lease = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (RateDimension dimension in rates)
|
||||||
|
{
|
||||||
|
tracker.WindowCounts[dimension.Key] =
|
||||||
|
tracker.WindowCounts.GetValueOrDefault(dimension.Key) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (concurrency.IsEmpty)
|
||||||
|
{
|
||||||
|
lease = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] acquiredConcurrency = new string[concurrency.Length];
|
||||||
|
for (int index = 0; index < concurrency.Length; index++)
|
||||||
|
{
|
||||||
|
RateDimension dimension = concurrency[index];
|
||||||
|
tracker.ConcurrencyCounts[dimension.Key] =
|
||||||
|
tracker.ConcurrencyCounts.GetValueOrDefault(dimension.Key) + 1;
|
||||||
|
acquiredConcurrency[index] = dimension.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
lease = new AbuseLease(this, tracker, acquiredConcurrency);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanAcquireAll(
|
||||||
|
TrackerState tracker,
|
||||||
|
Dictionary<string, int> counts,
|
||||||
|
ReadOnlySpan<RateDimension> dimensions,
|
||||||
|
int maxTrackedKeys,
|
||||||
|
ref int stagedNewKeys)
|
||||||
|
{
|
||||||
|
foreach (RateDimension dimension in dimensions)
|
||||||
|
{
|
||||||
|
if (counts.TryGetValue(dimension.Key, out int current))
|
||||||
|
{
|
||||||
|
if (current >= dimension.Limit)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stagedNewKeys++;
|
||||||
|
if (tracker.WindowCounts.Count + tracker.ConcurrencyCounts.Count + stagedNewKeys
|
||||||
|
> maxTrackedKeys)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Release(TrackerState tracker, string[] keys)
|
||||||
|
{
|
||||||
|
lock (tracker.Gate)
|
||||||
|
{
|
||||||
|
foreach (string key in keys)
|
||||||
|
{
|
||||||
|
if (!tracker.ConcurrencyCounts.TryGetValue(key, out int current))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current <= 1)
|
||||||
|
{
|
||||||
|
tracker.ConcurrencyCounts.Remove(key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tracker.ConcurrencyCounts[key] = current - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool DisposeAccepted(AbuseLease? lease)
|
||||||
|
{
|
||||||
|
lease?.Dispose();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLeaseCriticalOperation(string operation) => operation is
|
||||||
|
"RenewSessionLease" or "UpdateSession" or "DeleteSession";
|
||||||
|
|
||||||
|
private static string GetNetworkPrefix(IPAddress? address)
|
||||||
|
{
|
||||||
|
if (address is null)
|
||||||
|
{
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
|
||||||
|
byte[] bytes = normalized.GetAddressBytes();
|
||||||
|
if (bytes.Length == 4)
|
||||||
|
{
|
||||||
|
bytes[3] = 0;
|
||||||
|
return $"4:{Convert.ToHexString(bytes)}:24";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.Length == 16)
|
||||||
|
{
|
||||||
|
Array.Clear(bytes, 7, 9);
|
||||||
|
return $"6:{Convert.ToHexString(bytes)}:56";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPAddress NormalizeAddress(IPAddress address) =>
|
||||||
|
address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
|
||||||
|
|
||||||
|
private readonly record struct RateDimension(string Key, int Limit);
|
||||||
|
|
||||||
|
private enum TrackerDomain
|
||||||
|
{
|
||||||
|
Http,
|
||||||
|
Udp,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TrackerState(DateTimeOffset windowStartedAt)
|
||||||
|
{
|
||||||
|
public object Gate { get; } = new();
|
||||||
|
public Dictionary<string, int> WindowCounts { get; } = new(StringComparer.Ordinal);
|
||||||
|
public Dictionary<string, int> ConcurrencyCounts { get; } = new(StringComparer.Ordinal);
|
||||||
|
public DateTimeOffset WindowStartedAt { get; set; } = windowStartedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AbuseLease : IDisposable
|
||||||
|
{
|
||||||
|
private AbuseProtectionService? _owner;
|
||||||
|
private readonly TrackerState _tracker;
|
||||||
|
private readonly string[] _keys;
|
||||||
|
|
||||||
|
internal AbuseLease(
|
||||||
|
AbuseProtectionService owner,
|
||||||
|
TrackerState tracker,
|
||||||
|
string[] keys)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_tracker = tracker;
|
||||||
|
_keys = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _owner, null) is not null)
|
||||||
|
{
|
||||||
|
Release(_tracker, _keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
|
||||||
|
internal sealed class HttpAbuseProtectionMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
AbuseProtectionService protection)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
IHttpMaxRequestBodySizeFeature? bodySize =
|
||||||
|
context.Features.Get<IHttpMaxRequestBodySizeFeature>();
|
||||||
|
if (bodySize is { IsReadOnly: false })
|
||||||
|
{
|
||||||
|
bodySize.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
string operation = context.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()
|
||||||
|
?.EndpointName ?? "Unmatched";
|
||||||
|
bool healthEndpoint = operation is "GetLiveness" or "GetReadiness";
|
||||||
|
bool operatorEndpoint = operation is
|
||||||
|
"GetOperatorStatus"
|
||||||
|
or "RevokeOperatorListing"
|
||||||
|
or "RevokeOperatorPrincipal"
|
||||||
|
or "RevokeOperatorSigningKey"
|
||||||
|
or "BeginOperatorDrain";
|
||||||
|
if (operatorEndpoint
|
||||||
|
&& !protection.IsOperatorSourceAllowed(context.Connection.RemoteIpAddress))
|
||||||
|
{
|
||||||
|
bool deniedSourceAdmitted = protection.TryAcquireHttpIngress(
|
||||||
|
context.Connection.RemoteIpAddress,
|
||||||
|
"Unmatched",
|
||||||
|
out AbuseProtectionService.AbuseLease? deniedSourceLease,
|
||||||
|
out int deniedRetryAfterSeconds);
|
||||||
|
using (deniedSourceLease)
|
||||||
|
{
|
||||||
|
if (!deniedSourceAdmitted)
|
||||||
|
{
|
||||||
|
context.Response.Headers.RetryAfter = deniedRetryAfterSeconds.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
await WriteErrorAsync(
|
||||||
|
context,
|
||||||
|
StatusCodes.Status429TooManyRequests,
|
||||||
|
RendezvousErrorCode.RateLimited,
|
||||||
|
"The request rate limit was exceeded.",
|
||||||
|
deniedRetryAfterSeconds).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteErrorAsync(
|
||||||
|
context,
|
||||||
|
StatusCodes.Status404NotFound,
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
"The requested resource was not found.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AbuseProtectionService.AbuseLease? lease;
|
||||||
|
int retryAfterSeconds;
|
||||||
|
bool acquired;
|
||||||
|
if (healthEndpoint)
|
||||||
|
{
|
||||||
|
acquired = protection.TryAcquireHealthIngress(
|
||||||
|
context.Connection.RemoteIpAddress,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
else if (operatorEndpoint)
|
||||||
|
{
|
||||||
|
acquired = protection.TryAcquireOperatorIngress(
|
||||||
|
context.Connection.RemoteIpAddress,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
acquired = protection.TryAcquireHttpIngress(
|
||||||
|
context.Connection.RemoteIpAddress,
|
||||||
|
operation,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acquired)
|
||||||
|
{
|
||||||
|
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
await WriteErrorAsync(
|
||||||
|
context,
|
||||||
|
StatusCodes.Status429TooManyRequests,
|
||||||
|
RendezvousErrorCode.RateLimited,
|
||||||
|
"The request rate limit was exceeded.",
|
||||||
|
retryAfterSeconds).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (lease)
|
||||||
|
{
|
||||||
|
if (context.Request.ContentLength > ContractLimits.HttpRequestMaxBytes)
|
||||||
|
{
|
||||||
|
await WriteErrorAsync(
|
||||||
|
context,
|
||||||
|
StatusCodes.Status413PayloadTooLarge,
|
||||||
|
RendezvousErrorCode.InvalidRequest,
|
||||||
|
"The request body exceeds the supported size.").ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WriteErrorAsync(
|
||||||
|
HttpContext context,
|
||||||
|
int status,
|
||||||
|
RendezvousErrorCode code,
|
||||||
|
string message,
|
||||||
|
int? retryAfterSeconds = null)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = status;
|
||||||
|
return context.Response.WriteAsJsonAsync(
|
||||||
|
new ApiError
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Message = message,
|
||||||
|
RetryAfterSeconds = retryAfterSeconds,
|
||||||
|
},
|
||||||
|
ContractJson.Options,
|
||||||
|
contentType: "application/json",
|
||||||
|
cancellationToken: context.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
|
||||||
|
internal static class TrustedProxyForwarding
|
||||||
|
{
|
||||||
|
public static bool IsEnabled(AbuseProtectionOptions options) =>
|
||||||
|
options.TrustedProxyAddresses is { Length: > 0 };
|
||||||
|
|
||||||
|
public static void Configure(
|
||||||
|
ForwardedHeadersOptions forwarded,
|
||||||
|
AbuseProtectionOptions abuse)
|
||||||
|
{
|
||||||
|
forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
|
||||||
|
forwarded.ForwardLimit = 1;
|
||||||
|
forwarded.KnownProxies.Clear();
|
||||||
|
forwarded.KnownIPNetworks.Clear();
|
||||||
|
foreach (string address in abuse.TrustedProxyAddresses ?? [])
|
||||||
|
{
|
||||||
|
forwarded.KnownProxies.Add(IPAddress.Parse(address));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed class EphemeralCursorProtector : IDisposable
|
||||||
|
{
|
||||||
|
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public string Protect(string prefix, ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
string content = $"{prefix}.{EncodeBytes(payload)}";
|
||||||
|
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string cursor = $"{content}.{EncodeBytes(signature)}";
|
||||||
|
return ContractValidation.IsCursorValid(cursor)
|
||||||
|
? cursor
|
||||||
|
: throw new InvalidOperationException("The protected cursor exceeds its contract limit.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryUnprotect(string prefix, string? cursor, out byte[] payload)
|
||||||
|
{
|
||||||
|
payload = [];
|
||||||
|
if (_disposed || !ContractValidation.IsCursorValid(cursor))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] segments = cursor!.Split('.');
|
||||||
|
if (segments.Length != 3 || !string.Equals(segments[0], prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] expected = HMACSHA256.HashData(
|
||||||
|
_key,
|
||||||
|
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
|
||||||
|
if (!TryDecodeBytes(segments[2], out byte[] supplied))
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validSignature = supplied.Length == expected.Length
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
|
||||||
|
CryptographicOperations.ZeroMemory(supplied);
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
return validSignature && TryDecodeBytes(segments[1], out payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
CryptographicOperations.ZeroMemory(_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[EphemeralCursorProtector: key redacted]";
|
||||||
|
|
||||||
|
private static string EncodeBytes(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
|
||||||
|
private static bool TryDecodeBytes(string value, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
if (string.IsNullOrEmpty(value)
|
||||||
|
|| value.Any(static character =>
|
||||||
|
character is not (>= 'A' and <= 'Z')
|
||||||
|
and not (>= 'a' and <= 'z')
|
||||||
|
and not (>= '0' and <= '9')
|
||||||
|
and not '-'
|
||||||
|
and not '_'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string padded = value.Replace('-', '+').Replace('_', '/');
|
||||||
|
padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(padded);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed class SessionBrowserCursorCodec : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "rvc1";
|
||||||
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
|
|
||||||
|
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
BrowserCursorPayload payload = new()
|
||||||
|
{
|
||||||
|
GameId = query.Scope.GameId.Value,
|
||||||
|
EnvironmentId = query.Scope.EnvironmentId.Value,
|
||||||
|
ProtocolVersion = query.ProtocolVersion,
|
||||||
|
RegionId = query.RegionId?.Value,
|
||||||
|
ExcludeFull = query.ExcludeFull,
|
||||||
|
AfterListingId = after.ToString(),
|
||||||
|
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _protector.Protect(Prefix, encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDecode(
|
||||||
|
string? cursor,
|
||||||
|
TenantScope scope,
|
||||||
|
uint protocolVersion,
|
||||||
|
RegionId? regionId,
|
||||||
|
bool excludeFull,
|
||||||
|
DateTimeOffset now,
|
||||||
|
out SessionListingId? after)
|
||||||
|
{
|
||||||
|
after = null;
|
||||||
|
if (cursor is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserCursorPayload? payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.Deserialize<BrowserCursorPayload>(
|
||||||
|
encodedPayload,
|
||||||
|
ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is null
|
||||||
|
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||||
|
|| !string.Equals(payload.GameId, scope.GameId.Value, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ProtocolVersion != protocolVersion
|
||||||
|
|| !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal)
|
||||||
|
|| payload.ExcludeFull != excludeFull
|
||||||
|
|| !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
after = listingId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _protector.Dispose();
|
||||||
|
|
||||||
|
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class BrowserCursorPayload
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string GameId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string EnvironmentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public uint ProtocolVersion { get; set; }
|
||||||
|
|
||||||
|
public string? RegionId { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool ExcludeFull { get; set; }
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string AfterListingId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionBrowserService(
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
SessionBrowserCursorCodec cursors,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
||||||
|
BrowseSessionsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = Validate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
TenantScope scope = new(request.GameId, request.EnvironmentId);
|
||||||
|
if (!cursors.TryDecode(
|
||||||
|
request.Cursor,
|
||||||
|
scope,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.RegionId,
|
||||||
|
request.ExcludeFull,
|
||||||
|
clock.UtcNow,
|
||||||
|
out SessionListingId? after))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
VisibleListingQuery query = new(
|
||||||
|
scope,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.RegionId,
|
||||||
|
request.PageSize + 1,
|
||||||
|
after,
|
||||||
|
request.ExcludeFull);
|
||||||
|
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
|
||||||
|
query,
|
||||||
|
cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||||
|
? RendezvousErrorCode.ServiceUnavailable
|
||||||
|
: RendezvousErrorCode.InternalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SessionListing> items = found.Value
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.Select(ToContract)
|
||||||
|
.ToList();
|
||||||
|
bool hasMore = found.Value.Count > request.PageSize;
|
||||||
|
while (items.Count > 0)
|
||||||
|
{
|
||||||
|
string? nextCursor = hasMore
|
||||||
|
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
||||||
|
: null;
|
||||||
|
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
|
||||||
|
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
||||||
|
<= ContractLimits.BrowserResponseMaxBytes)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.None, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.RemoveAt(items.Count - 1);
|
||||||
|
hasMore = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public BrowserServiceResult<GetSessionResponse> Get(
|
||||||
|
SessionListingId listingId,
|
||||||
|
GameId gameId,
|
||||||
|
EnvironmentId environmentId,
|
||||||
|
uint protocolVersion,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (listingId.Value == Guid.Empty
|
||||||
|
|| string.IsNullOrEmpty(gameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(environmentId.Value)
|
||||||
|
|| protocolVersion == 0)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> found = store.GetListing(listingId, true, cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||||
|
? RendezvousErrorCode.ServiceUnavailable
|
||||||
|
: RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing listing = found.Value;
|
||||||
|
if (listing.Definition.Scope != new TenantScope(gameId, environmentId)
|
||||||
|
|| listing.Definition.ProtocolVersion != protocolVersion)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new GetSessionResponse
|
||||||
|
{
|
||||||
|
Session = ToContract(listing),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
|| (request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value))
|
||||||
|
|| !ContractValidation.IsPageSizeValid(request.PageSize)
|
||||||
|
|| !ContractValidation.IsCursorValid(request.Cursor)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SessionListing ToContract(StoredListing stored) => new()
|
||||||
|
{
|
||||||
|
ListingId = stored.Definition.ListingId,
|
||||||
|
GameId = stored.Definition.Scope.GameId,
|
||||||
|
EnvironmentId = stored.Definition.Scope.EnvironmentId,
|
||||||
|
RegionId = stored.Definition.RegionId,
|
||||||
|
ProtocolVersion = stored.Definition.ProtocolVersion,
|
||||||
|
BuildVersion = stored.Definition.BuildVersion,
|
||||||
|
DisplayName = stored.Definition.DisplayName,
|
||||||
|
Visibility = stored.Definition.Visibility,
|
||||||
|
PublisherTrustMode = stored.Definition.TrustMode,
|
||||||
|
Capacity = new()
|
||||||
|
{
|
||||||
|
CurrentPlayers = stored.Definition.CurrentPlayers,
|
||||||
|
MaximumPlayers = stored.Definition.MaximumPlayers,
|
||||||
|
},
|
||||||
|
Metadata = stored.Definition.Metadata.ToDictionary(
|
||||||
|
static item => item.Key,
|
||||||
|
static item => item.Value,
|
||||||
|
StringComparer.Ordinal),
|
||||||
|
DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||||
|
|
||||||
|
internal sealed record ConnectionOutcomeServiceResult(
|
||||||
|
RendezvousErrorCode Error,
|
||||||
|
ReportConnectionOutcomeResponse? Value = null)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ConnectionOutcomeMetrics
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<(ConnectionOutcomeKind, ConnectionElapsedBucket), long> _counts = [];
|
||||||
|
private readonly RendezvousTelemetry? _telemetry;
|
||||||
|
|
||||||
|
public ConnectionOutcomeMetrics(RendezvousTelemetry? telemetry = null) =>
|
||||||
|
_telemetry = telemetry;
|
||||||
|
|
||||||
|
internal void Record(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
(ConnectionOutcomeKind, ConnectionElapsedBucket) key = (outcome, elapsedBucket);
|
||||||
|
_counts.TryGetValue(key, out long count);
|
||||||
|
_counts[key] = count + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_telemetry?.RecordConnectionOutcome(outcome.ToString(), elapsedBucket.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal long GetCount(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _counts.GetValueOrDefault((outcome, elapsedBucket));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ConnectionOutcomeService(
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
ConnectionOutcomeMetrics metrics)
|
||||||
|
{
|
||||||
|
internal ConnectionOutcomeServiceResult Report(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string? clientPunchCapability,
|
||||||
|
ReportConnectionOutcomeRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(
|
||||||
|
request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(clientPunchCapability)
|
||||||
|
|| !TryNormalizeReport(request, out ConnectionOutcomeKind outcome, out ConnectionElapsedBucket elapsedBucket)
|
||||||
|
|| !capabilities.TryFingerprint(
|
||||||
|
clientPunchCapability,
|
||||||
|
out SecretFingerprint capabilityFingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredConnectionOutcome> reported = store.ReportConnectionOutcome(new(
|
||||||
|
attemptId,
|
||||||
|
capabilityFingerprint,
|
||||||
|
outcome,
|
||||||
|
elapsedBucket), cancellationToken);
|
||||||
|
if (!reported.Succeeded)
|
||||||
|
{
|
||||||
|
return new(reported.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reported.IsIdempotentReplay)
|
||||||
|
{
|
||||||
|
metrics.Record(outcome, elapsedBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new ReportConnectionOutcomeResponse
|
||||||
|
{
|
||||||
|
Accepted = true,
|
||||||
|
IsDuplicate = reported.IsIdempotentReplay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNormalizeReport(
|
||||||
|
ReportConnectionOutcomeRequest request,
|
||||||
|
out ConnectionOutcomeKind outcome,
|
||||||
|
out ConnectionElapsedBucket elapsedBucket)
|
||||||
|
{
|
||||||
|
outcome = request.Outcome switch
|
||||||
|
{
|
||||||
|
ConnectionOutcomeKind.TimedOut => ConnectionOutcomeKind.PunchTimedOut,
|
||||||
|
ConnectionOutcomeKind.StaleHost => ConnectionOutcomeKind.NoHostPresence,
|
||||||
|
ConnectionOutcomeKind.TransportFailed => ConnectionOutcomeKind.TransportError,
|
||||||
|
_ => request.Outcome,
|
||||||
|
};
|
||||||
|
if (!ContractValidation.IsReportableConnectionOutcome(request.Outcome))
|
||||||
|
{
|
||||||
|
elapsedBucket = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Enum.IsDefined(request.ElapsedBucket))
|
||||||
|
{
|
||||||
|
elapsedBucket = request.ElapsedBucket;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma warning disable CS0618 // Frozen v1 compatibility input; never retained at exact precision.
|
||||||
|
if (request.ElapsedBucket == default && request.ElapsedMilliseconds >= 0)
|
||||||
|
{
|
||||||
|
elapsedBucket = BucketElapsedMilliseconds(request.ElapsedMilliseconds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
|
elapsedBucket = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConnectionElapsedBucket BucketElapsedMilliseconds(int elapsedMilliseconds) =>
|
||||||
|
elapsedMilliseconds switch
|
||||||
|
{
|
||||||
|
< 1_000 => ConnectionElapsedBucket.UnderOneSecond,
|
||||||
|
< 5_000 => ConnectionElapsedBucket.OneToFiveSeconds,
|
||||||
|
< 15_000 => ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||||
|
< 30_000 => ConnectionElapsedBucket.FifteenToThirtySeconds,
|
||||||
|
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Deployment;
|
||||||
|
|
||||||
|
internal sealed record DeploymentOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Rendezvous:Deployment";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string PublicHttpBaseUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string PublicUdpHost { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[Range(1, 65_535)]
|
||||||
|
public int PublicUdpPort { get; init; } = 9050;
|
||||||
|
|
||||||
|
[Range(1, 30)]
|
||||||
|
public int DrainDeadlineSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
[Range(0, 5)]
|
||||||
|
public int MinimumDrainSeconds { get; init; } = 1;
|
||||||
|
|
||||||
|
public bool SingleActiveInstance { get; init; } = true;
|
||||||
|
|
||||||
|
public bool AllowPrivatePublicEndpoints { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> ValidateProduction(
|
||||||
|
AbuseProtectionOptions abuseProtection,
|
||||||
|
string? allowedHosts)
|
||||||
|
{
|
||||||
|
List<string> errors = [];
|
||||||
|
if (!SingleActiveInstance)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:SingleActiveInstance must be true because ephemeral state is not shared between replicas.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MinimumDrainSeconds >= DrainDeadlineSeconds)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:MinimumDrainSeconds must be less than DrainDeadlineSeconds.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DrainDeadlineSeconds is < 1 or > 30)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:DrainDeadlineSeconds must be between 1 and 30.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MinimumDrainSeconds is < 0 or > 5)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:MinimumDrainSeconds must be between 0 and 5.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PublicUdpPort is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:PublicUdpPort must be between 1 and 65535.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateHttpEndpoint(errors);
|
||||||
|
ValidateUdpEndpoint(errors);
|
||||||
|
ValidateAllowedHosts(errors, allowedHosts, PublicHttpBaseUrl);
|
||||||
|
|
||||||
|
if (abuseProtection.TrustedProxyAddresses is not { Length: > 0 })
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:AbuseProtection:TrustedProxyAddresses must list the exact TLS proxy addresses; forwarded headers are rejected without this trust boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateHttpEndpoint(List<string> errors)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(PublicHttpBaseUrl, UriKind.Absolute, out Uri? endpoint)
|
||||||
|
|| !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
|
||||||
|
|| !string.IsNullOrEmpty(endpoint.UserInfo)
|
||||||
|
|| !string.IsNullOrEmpty(endpoint.Query)
|
||||||
|
|| !string.IsNullOrEmpty(endpoint.Fragment)
|
||||||
|
|| endpoint.AbsolutePath != "/")
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:Deployment:PublicHttpBaseUrl must be an absolute HTTPS origin with no credentials, path, query, or fragment (for example, https://rendezvous.your-company.tld/).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AllowPrivatePublicEndpoints && !IsPublicHost(endpoint.Host))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:Deployment:PublicHttpBaseUrl must use a public DNS name or address; set AllowPrivatePublicEndpoints=true only for an isolated deployment smoke test.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateUdpEndpoint(List<string> errors)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PublicUdpHost)
|
||||||
|
|| PublicUdpHost.Contains("//", StringComparison.Ordinal)
|
||||||
|
|| PublicUdpHost.Contains(':', StringComparison.Ordinal) && !IPAddress.TryParse(PublicUdpHost, out _)
|
||||||
|
|| Uri.CheckHostName(PublicUdpHost) == UriHostNameType.Unknown)
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:Deployment:PublicUdpHost must contain only the advertised DNS name or IP address; configure the port separately.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AllowPrivatePublicEndpoints && !IsPublicHost(PublicUdpHost))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:Deployment:PublicUdpHost must use a public DNS name or address; set AllowPrivatePublicEndpoints=true only for an isolated deployment smoke test.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateAllowedHosts(
|
||||||
|
List<string> errors,
|
||||||
|
string? allowedHosts,
|
||||||
|
string publicHttpBaseUrl)
|
||||||
|
{
|
||||||
|
string[] hosts = (allowedHosts ?? string.Empty).Split(
|
||||||
|
';',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (hosts.Length == 0 || hosts.Any(static host => host is "*" or "+"))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"AllowedHosts must explicitly list the public HTTP host in production; wildcard or empty host filtering is unsafe.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Uri.TryCreate(publicHttpBaseUrl, UriKind.Absolute, out Uri? endpoint)
|
||||||
|
&& !hosts.Contains(endpoint.Host, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"AllowedHosts must contain the exact host advertised by Rendezvous:Deployment:PublicHttpBaseUrl.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPublicHost(string host)
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(host, out IPAddress? address))
|
||||||
|
{
|
||||||
|
return Uri.CheckHostName(host) == UriHostNameType.Dns
|
||||||
|
&& host.Contains('.', StringComparison.Ordinal)
|
||||||
|
&& !IsReservedDnsName(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsGloballyRoutableUnicast(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsReservedDnsName(string host)
|
||||||
|
{
|
||||||
|
string normalized = host.TrimEnd('.');
|
||||||
|
string[] reservedSuffixes =
|
||||||
|
[
|
||||||
|
"localhost",
|
||||||
|
"local",
|
||||||
|
"invalid",
|
||||||
|
"test",
|
||||||
|
"example",
|
||||||
|
"example.com",
|
||||||
|
"example.net",
|
||||||
|
"example.org",
|
||||||
|
"home.arpa",
|
||||||
|
"alt",
|
||||||
|
"onion",
|
||||||
|
];
|
||||||
|
return reservedSuffixes.Any(suffix =>
|
||||||
|
string.Equals(normalized, suffix, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| normalized.EndsWith($".{suffix}", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGloballyRoutableUnicast(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address.IsIPv4MappedToIPv6)
|
||||||
|
{
|
||||||
|
address = address.MapToIPv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
return !(bytes[0] is 0 or 10 or 127
|
||||||
|
|| bytes[0] == 100 && bytes[1] is >= 64 and <= 127
|
||||||
|
|| bytes[0] == 169 && bytes[1] == 254
|
||||||
|
|| bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|
||||||
|
|| bytes[0] == 192
|
||||||
|
&& (bytes[1] == 0 && bytes[2] is 0 or 2
|
||||||
|
|| bytes[1] == 88 && bytes[2] == 99
|
||||||
|
|| bytes[1] == 168)
|
||||||
|
|| bytes[0] == 198
|
||||||
|
&& (bytes[1] is 18 or 19
|
||||||
|
|| bytes[1] == 51 && bytes[2] == 100)
|
||||||
|
|| bytes[0] == 203 && bytes[1] == 0 && bytes[2] == 113
|
||||||
|
|| bytes[0] >= 224);
|
||||||
|
}
|
||||||
|
|
||||||
|
return address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6
|
||||||
|
&& !IPAddress.IsLoopback(address)
|
||||||
|
&& !address.Equals(IPAddress.IPv6Any)
|
||||||
|
&& !address.IsIPv6LinkLocal
|
||||||
|
&& !address.IsIPv6SiteLocal
|
||||||
|
&& !address.IsIPv6Multicast
|
||||||
|
&& (bytes[0] & 0xe0) == 0x20
|
||||||
|
&& !HasPrefix(bytes, [0x20, 0x01, 0x00], 23)
|
||||||
|
&& !HasPrefix(bytes, [0x20, 0x01, 0x0d, 0xb8], 32)
|
||||||
|
&& !HasPrefix(bytes, [0x20, 0x02], 16)
|
||||||
|
&& !HasPrefix(bytes, [0x3f, 0xff, 0x00], 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasPrefix(byte[] address, byte[] prefix, int bitCount)
|
||||||
|
{
|
||||||
|
int fullBytes = bitCount / 8;
|
||||||
|
for (int index = 0; index < fullBytes; index++)
|
||||||
|
{
|
||||||
|
if (address[index] != prefix[index])
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int remainingBits = bitCount % 8;
|
||||||
|
if (remainingBits == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int mask = 0xff << (8 - remainingBits);
|
||||||
|
return (address[fullBytes] & mask) == (prefix[fullBytes] & mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class DeploymentConfigurationException(IReadOnlyList<string> errors)
|
||||||
|
: InvalidOperationException(
|
||||||
|
"Production deployment configuration is invalid:" + Environment.NewLine
|
||||||
|
+ string.Join(Environment.NewLine, errors.Select(static error => $"- {error}")))
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Deployment;
|
||||||
|
|
||||||
|
internal sealed partial class GracefulDrainService : IHostedService, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||||
|
private readonly InMemoryEphemeralRendezvousStore _store;
|
||||||
|
private readonly IHostApplicationLifetime _lifetime;
|
||||||
|
private readonly DeploymentOptions _options;
|
||||||
|
private readonly ILogger<GracefulDrainService> _logger;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private CancellationTokenRegistration _stoppingRegistration;
|
||||||
|
private Task? _drainTask;
|
||||||
|
|
||||||
|
public GracefulDrainService(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
IHostApplicationLifetime lifetime,
|
||||||
|
IOptions<DeploymentOptions> options,
|
||||||
|
ILogger<GracefulDrainService> logger)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_lifetime = lifetime;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
_stoppingRegistration = _lifetime.ApplicationStopping.Register(
|
||||||
|
() => EnsureDrainAsync().GetAwaiter().GetResult());
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// ApplicationStopping callbacks run before hosted services and listeners
|
||||||
|
// stop. StopAsync is the idempotent fallback for directly driven hosts.
|
||||||
|
_ = cancellationToken;
|
||||||
|
return EnsureDrainAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _stoppingRegistration.Dispose();
|
||||||
|
|
||||||
|
private Task EnsureDrainAsync()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _drainTask ??= DrainAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DrainAsync()
|
||||||
|
{
|
||||||
|
_store.BeginDrain(CancellationToken.None);
|
||||||
|
TimeSpan deadline = TimeSpan.FromSeconds(_options.DrainDeadlineSeconds);
|
||||||
|
TimeSpan minimum = TimeSpan.FromSeconds(_options.MinimumDrainSeconds);
|
||||||
|
long startedAt = Stopwatch.GetTimestamp();
|
||||||
|
LogDrainStarted(_logger, _options.DrainDeadlineSeconds);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (Stopwatch.GetElapsedTime(startedAt) < deadline)
|
||||||
|
{
|
||||||
|
TimeSpan elapsed = Stopwatch.GetElapsedTime(startedAt);
|
||||||
|
if (elapsed >= minimum && _store.GetActiveJoinAttemptCountForDrain() == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan remaining = deadline - elapsed;
|
||||||
|
await Task.Delay(
|
||||||
|
remaining < PollInterval ? remaining : PollInterval,
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_store.MarkUnavailable();
|
||||||
|
double elapsedMilliseconds = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
||||||
|
LogDrainFinished(_logger, elapsedMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 1,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Graceful drain started with a {DrainDeadlineSeconds}-second deadline")]
|
||||||
|
private static partial void LogDrainStarted(ILogger logger, int drainDeadlineSeconds);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared")]
|
||||||
|
private static partial void LogDrainFinished(ILogger logger, double elapsedMilliseconds);
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
using System.Net;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Http;
|
namespace FinalFactory.Rendezvous.Server.Http;
|
||||||
|
|
||||||
internal static class ContractEndpoints
|
internal static class ContractEndpoints
|
||||||
{
|
{
|
||||||
private const int NotImplementedStatus = StatusCodes.Status501NotImplemented;
|
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
|
public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
|
||||||
this IEndpointRouteBuilder endpoints)
|
this IEndpointRouteBuilder endpoints)
|
||||||
{
|
{
|
||||||
@@ -14,34 +20,68 @@ internal static class ContractEndpoints
|
|||||||
sessions.MapPost("/", RegisterSession)
|
sessions.MapPost("/", RegisterSession)
|
||||||
.Accepts<RegisterSessionRequest>("application/json")
|
.Accepts<RegisterSessionRequest>("application/json")
|
||||||
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
|
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("RegisterSession");
|
.WithName("RegisterSession");
|
||||||
sessions.MapPost("/{listingId}/renew", RenewLease)
|
sessions.MapPost("/{listingId}/renew", RenewLease)
|
||||||
.Accepts<RenewLeaseRequest>("application/json")
|
.Accepts<RenewLeaseRequest>("application/json")
|
||||||
.Produces<RenewLeaseResponse>()
|
.Produces<RenewLeaseResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.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");
|
.WithName("RenewSessionLease");
|
||||||
sessions.MapPut("/{listingId}", UpdateSession)
|
sessions.MapPut("/{listingId}", UpdateSession)
|
||||||
.Accepts<UpdateSessionRequest>("application/json")
|
.Accepts<UpdateSessionRequest>("application/json")
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.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");
|
.WithName("UpdateSession");
|
||||||
sessions.MapDelete("/{listingId}", DeleteSession)
|
sessions.MapDelete("/{listingId}", DeleteSession)
|
||||||
.Accepts<DeleteSessionRequest>("application/json")
|
.Accepts<DeleteSessionRequest>("application/json")
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.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");
|
.WithName("DeleteSession");
|
||||||
sessions.MapGet("/", BrowseSessions)
|
sessions.MapGet("/", BrowseSessions)
|
||||||
.Produces<BrowseSessionsResponse>()
|
.Produces<BrowseSessionsResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("BrowseSessions");
|
.WithName("BrowseSessions");
|
||||||
sessions.MapGet("/{listingId}", GetSession)
|
sessions.MapGet("/{listingId}", GetSession)
|
||||||
.Produces<GetSessionResponse>()
|
.Produces<GetSessionResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("GetSession");
|
.WithName("GetSession");
|
||||||
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
||||||
.Produces<BrowseHostJoinAttemptsResponse>()
|
.Produces<BrowseHostJoinAttemptsResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("BrowseHostJoinAttempts");
|
.WithName("BrowseHostJoinAttempts");
|
||||||
|
|
||||||
RouteGroupBuilder attempts = endpoints
|
RouteGroupBuilder attempts = endpoints
|
||||||
@@ -50,31 +90,212 @@ internal static class ContractEndpoints
|
|||||||
attempts.MapPost("/", CreateJoinAttempt)
|
attempts.MapPost("/", CreateJoinAttempt)
|
||||||
.Accepts<CreateJoinAttemptRequest>("application/json")
|
.Accepts<CreateJoinAttemptRequest>("application/json")
|
||||||
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("CreateJoinAttempt");
|
.WithName("CreateJoinAttempt");
|
||||||
|
attempts.MapDelete("/{attemptId}", CancelJoinAttempt)
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("CancelJoinAttempt");
|
||||||
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
||||||
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
||||||
.Produces<ReportConnectionOutcomeResponse>()
|
.Produces<ReportConnectionOutcomeResponse>()
|
||||||
.Produces<ApiError>(NotImplementedStatus)
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
.WithName("ReportConnectionOutcome");
|
.WithName("ReportConnectionOutcome");
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult RegisterSession([FromBody] RegisterSessionRequest request) =>
|
private static IResult RegisterSession(
|
||||||
NotImplemented();
|
[FromBody] RegisterSessionRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] SessionLeaseService sessions,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthenticatePublisher(
|
||||||
|
authorizationHeader,
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
out AuthenticatedPrincipal? principal))
|
||||||
|
{
|
||||||
|
return AuthenticationRequired(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"RegisterSession",
|
||||||
|
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||||
|
publisher.Subject,
|
||||||
|
null,
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
|
||||||
|
principal!,
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult RenewLease(
|
private static IResult RenewLease(
|
||||||
SessionListingId listingId,
|
SessionListingId listingId,
|
||||||
[FromBody] RenewLeaseRequest request) => NotImplemented();
|
[FromBody] RenewLeaseRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] SessionLeaseService sessions,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthenticatePublisher(
|
||||||
|
authorizationHeader,
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
out AuthenticatedPrincipal? principal))
|
||||||
|
{
|
||||||
|
return AuthenticationRequired(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"RenewSessionLease",
|
||||||
|
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||||
|
publisher.Subject,
|
||||||
|
listingId.ToString(),
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
|
||||||
|
principal!,
|
||||||
|
listingId,
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult UpdateSession(
|
private static IResult UpdateSession(
|
||||||
SessionListingId listingId,
|
SessionListingId listingId,
|
||||||
[FromBody] UpdateSessionRequest request) => NotImplemented();
|
[FromBody] UpdateSessionRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] SessionLeaseService sessions,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthenticatePublisher(
|
||||||
|
authorizationHeader,
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
out AuthenticatedPrincipal? principal))
|
||||||
|
{
|
||||||
|
return AuthenticationRequired(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"UpdateSession",
|
||||||
|
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||||
|
publisher.Subject,
|
||||||
|
listingId.ToString(),
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
SessionServiceResult<bool> result = sessions.Update(
|
||||||
|
principal!,
|
||||||
|
listingId,
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult DeleteSession(
|
private static IResult DeleteSession(
|
||||||
SessionListingId listingId,
|
SessionListingId listingId,
|
||||||
[FromBody] DeleteSessionRequest request) => NotImplemented();
|
[FromBody] DeleteSessionRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] SessionLeaseService sessions,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthenticatePublisher(
|
||||||
|
authorizationHeader,
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
out AuthenticatedPrincipal? principal))
|
||||||
|
{
|
||||||
|
return AuthenticationRequired(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"DeleteSession",
|
||||||
|
Tenant(publisher.GameId, publisher.EnvironmentId),
|
||||||
|
publisher.Subject,
|
||||||
|
listingId.ToString(),
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
SessionServiceResult<bool> result = sessions.Delete(
|
||||||
|
principal!,
|
||||||
|
listingId,
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult BrowseSessions(
|
private static IResult BrowseSessions(
|
||||||
[FromQuery] int contractVersion,
|
[FromQuery] int contractVersion,
|
||||||
@@ -83,30 +304,349 @@ internal static class ContractEndpoints
|
|||||||
[FromQuery] uint protocolVersion,
|
[FromQuery] uint protocolVersion,
|
||||||
[FromQuery] string? regionId,
|
[FromQuery] string? regionId,
|
||||||
[FromQuery] int? pageSize,
|
[FromQuery] int? pageSize,
|
||||||
[FromQuery] string? cursor) => NotImplemented();
|
[FromQuery] bool? excludeFull,
|
||||||
|
[FromQuery] string? cursor,
|
||||||
|
[FromServices] SessionBrowserService browser,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||||
|
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|
||||||
|
|| (regionId is not null && !RegionId.TryParse(regionId, out _)))
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult GetSession(SessionListingId listingId) => NotImplemented();
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"BrowseSessions",
|
||||||
|
Tenant(parsedGameId, parsedEnvironmentId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
|
||||||
|
{
|
||||||
|
ContractVersion = contractVersion,
|
||||||
|
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(
|
||||||
|
SessionListingId listingId,
|
||||||
|
[FromQuery] int contractVersion,
|
||||||
|
[FromQuery] string gameId,
|
||||||
|
[FromQuery] string environmentId,
|
||||||
|
[FromQuery] uint protocolVersion,
|
||||||
|
[FromServices] SessionBrowserService browser,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.UnsupportedContractVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||||
|
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId))
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"GetSession",
|
||||||
|
Tenant(parsedGameId, parsedEnvironmentId),
|
||||||
|
null,
|
||||||
|
listingId.ToString(),
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
BrowserServiceResult<GetSessionResponse> result = browser.Get(
|
||||||
|
listingId,
|
||||||
|
parsedGameId,
|
||||||
|
parsedEnvironmentId,
|
||||||
|
protocolVersion,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult BrowseHostJoinAttempts(
|
private static IResult BrowseHostJoinAttempts(
|
||||||
SessionListingId listingId,
|
SessionListingId listingId,
|
||||||
[FromQuery] int contractVersion,
|
[FromQuery] int contractVersion,
|
||||||
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
|
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
|
||||||
[FromQuery] int? pageSize,
|
[FromQuery] int? pageSize,
|
||||||
[FromQuery] string? cursor) => NotImplemented();
|
[FromQuery] string? cursor,
|
||||||
|
[FromServices] JoinAttemptService attempts,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"BrowseHostJoinAttempts",
|
||||||
|
null,
|
||||||
|
AbuseProtectionService.FingerprintSecret(leaseToken ?? string.Empty),
|
||||||
|
listingId.ToString(),
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult CreateJoinAttempt([FromBody] CreateJoinAttemptRequest request) =>
|
using (abuseLease)
|
||||||
NotImplemented();
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress)
|
||||||
|
{
|
||||||
|
return Error(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"CreateJoinAttempt",
|
||||||
|
Tenant(request.GameId, request.EnvironmentId),
|
||||||
|
clientSubject,
|
||||||
|
request.ListingId.ToString(),
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
|
||||||
|
clientSubject,
|
||||||
|
request,
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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(
|
private static IResult ReportConnectionOutcome(
|
||||||
JoinAttemptId attemptId,
|
JoinAttemptId attemptId,
|
||||||
[FromBody] ReportConnectionOutcomeRequest request) => NotImplemented();
|
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
|
||||||
|
[FromBody] ReportConnectionOutcomeRequest request,
|
||||||
|
[FromServices] ConnectionOutcomeService outcomes,
|
||||||
|
[FromServices] AbuseProtectionService abuseProtection,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAcquireIdentity(
|
||||||
|
abuseProtection,
|
||||||
|
httpContext,
|
||||||
|
"ReportConnectionOutcome",
|
||||||
|
null,
|
||||||
|
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
|
||||||
|
attemptId.ToString(),
|
||||||
|
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||||
|
{
|
||||||
|
return RateLimited(httpContext);
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult NotImplemented() => Results.Json(
|
using (abuseLease)
|
||||||
|
{
|
||||||
|
ConnectionOutcomeServiceResult result = outcomes.Report(
|
||||||
|
attemptId,
|
||||||
|
clientPunchCapability,
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Succeeded && result.Value is not null
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: Error(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryAuthenticatePublisher(
|
||||||
|
string? authorizationHeader,
|
||||||
|
PrincipalCredentialService credentials,
|
||||||
|
IWallClock clock,
|
||||||
|
out AuthenticatedPrincipal? principal)
|
||||||
|
{
|
||||||
|
principal = null;
|
||||||
|
const string bearerPrefix = "Bearer ";
|
||||||
|
if (authorizationHeader is null
|
||||||
|
|| !authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string token = authorizationHeader[bearerPrefix.Length..];
|
||||||
|
CredentialValidationResult validation = credentials.Validate(token, clock.UtcNow);
|
||||||
|
if (!validation.IsValid || validation.Principal is not IPublisherPrincipal)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
principal = validation.Principal;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryAcquireIdentity(
|
||||||
|
AbuseProtectionService abuseProtection,
|
||||||
|
HttpContext httpContext,
|
||||||
|
string operation,
|
||||||
|
string? tenant,
|
||||||
|
string? principal,
|
||||||
|
string? resource,
|
||||||
|
out AbuseProtectionService.AbuseLease? lease)
|
||||||
|
{
|
||||||
|
if (abuseProtection.TryAcquireHttpIdentity(
|
||||||
|
operation,
|
||||||
|
httpContext.Connection.RemoteIpAddress,
|
||||||
|
tenant,
|
||||||
|
principal,
|
||||||
|
resource,
|
||||||
|
out lease,
|
||||||
|
out int retryAfterSeconds))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
httpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Tenant(GameId gameId, EnvironmentId environmentId) =>
|
||||||
|
$"{gameId.Value}/{environmentId.Value}";
|
||||||
|
|
||||||
|
private static IResult Error(RendezvousErrorCode code, int? retryAfterSeconds = null) => Results.Json(
|
||||||
new ApiError
|
new ApiError
|
||||||
{
|
{
|
||||||
Code = RendezvousErrorCode.ServiceUnavailable,
|
Code = code,
|
||||||
Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.",
|
Message = ErrorMessage(code),
|
||||||
|
RetryAfterSeconds = retryAfterSeconds,
|
||||||
},
|
},
|
||||||
ContractJson.Options,
|
ContractJson.Options,
|
||||||
statusCode: NotImplementedStatus);
|
statusCode: ErrorStatus(code));
|
||||||
|
|
||||||
|
private static IResult AuthenticationRequired(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.Headers.WWWAuthenticate = "Bearer";
|
||||||
|
return Error(RendezvousErrorCode.AuthenticationRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RateLimited(HttpContext context)
|
||||||
|
{
|
||||||
|
int? retryAfterSeconds = int.TryParse(
|
||||||
|
context.Response.Headers.RetryAfter,
|
||||||
|
System.Globalization.NumberStyles.None,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out int parsed)
|
||||||
|
? Math.Clamp(parsed, 1, 60)
|
||||||
|
: null;
|
||||||
|
return Error(RendezvousErrorCode.RateLimited, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ErrorStatus(RendezvousErrorCode code) => code switch
|
||||||
|
{
|
||||||
|
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
|
||||||
|
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
|
||||||
|
RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound,
|
||||||
|
RendezvousErrorCode.Conflict
|
||||||
|
or RendezvousErrorCode.IncompatibleProtocol
|
||||||
|
or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
|
||||||
|
RendezvousErrorCode.Expired or RendezvousErrorCode.StaleHost => StatusCodes.Status410Gone,
|
||||||
|
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
|
||||||
|
StatusCodes.Status429TooManyRequests,
|
||||||
|
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
|
||||||
|
RendezvousErrorCode.InternalError => StatusCodes.Status500InternalServerError,
|
||||||
|
_ => StatusCodes.Status400BadRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ErrorMessage(RendezvousErrorCode code) => code switch
|
||||||
|
{
|
||||||
|
RendezvousErrorCode.AuthenticationRequired => "A valid publisher bearer credential is required.",
|
||||||
|
RendezvousErrorCode.Forbidden => "The publisher is not authorized for this operation.",
|
||||||
|
RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.",
|
||||||
|
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
|
||||||
|
RendezvousErrorCode.Expired => "The session lease has expired.",
|
||||||
|
RendezvousErrorCode.StaleHost => "The session has no fresh host presence.",
|
||||||
|
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
|
||||||
|
RendezvousErrorCode.RateLimited => "The request rate limit was exceeded.",
|
||||||
|
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
|
||||||
|
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
|
||||||
|
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
|
||||||
|
_ => "The session request is invalid.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Http;
|
||||||
|
|
||||||
|
internal sealed partial class RendezvousExceptionHandler(
|
||||||
|
ILogger<RendezvousExceptionHandler> logger) : IExceptionHandler
|
||||||
|
{
|
||||||
|
public async ValueTask<bool> TryHandleAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (httpContext.Response.HasStarted)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool invalidRequest = exception is BadHttpRequestException or JsonException;
|
||||||
|
bool payloadTooLarge = exception is BadHttpRequestException
|
||||||
|
{
|
||||||
|
StatusCode: StatusCodes.Status413PayloadTooLarge,
|
||||||
|
};
|
||||||
|
httpContext.Response.StatusCode = payloadTooLarge
|
||||||
|
? StatusCodes.Status413PayloadTooLarge
|
||||||
|
: invalidRequest
|
||||||
|
? StatusCodes.Status400BadRequest
|
||||||
|
: StatusCodes.Status500InternalServerError;
|
||||||
|
LogRequestFailure(
|
||||||
|
logger,
|
||||||
|
payloadTooLarge ? "payload-too-large" : invalidRequest ? "invalid-request" : "internal-error",
|
||||||
|
httpContext.Response.StatusCode,
|
||||||
|
httpContext.Response.Headers["X-Rendezvous-Correlation-ID"].ToString() is { Length: > 0 } value
|
||||||
|
? value
|
||||||
|
: "unavailable");
|
||||||
|
await httpContext.Response.WriteAsJsonAsync(
|
||||||
|
new ApiError
|
||||||
|
{
|
||||||
|
Code = invalidRequest
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.InternalError,
|
||||||
|
Message = 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);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 200,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}")]
|
||||||
|
private static partial void LogRequestFailure(
|
||||||
|
ILogger logger,
|
||||||
|
string failureKind,
|
||||||
|
int statusCode,
|
||||||
|
string correlationId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptCursorCodec : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "rvj1";
|
||||||
|
private readonly EphemeralCursorProtector _protector = new();
|
||||||
|
|
||||||
|
public string Encode(
|
||||||
|
SessionListingId listingId,
|
||||||
|
JoinAttemptId after,
|
||||||
|
DateTimeOffset now)
|
||||||
|
{
|
||||||
|
JoinAttemptCursorPayload payload = new()
|
||||||
|
{
|
||||||
|
ListingId = listingId.ToString(),
|
||||||
|
AfterAttemptId = after.ToString(),
|
||||||
|
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _protector.Protect(Prefix, encoded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDecode(
|
||||||
|
string? cursor,
|
||||||
|
SessionListingId listingId,
|
||||||
|
DateTimeOffset now,
|
||||||
|
out JoinAttemptId? after)
|
||||||
|
{
|
||||||
|
after = null;
|
||||||
|
if (cursor is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinAttemptCursorPayload? payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.Deserialize<JoinAttemptCursorPayload>(
|
||||||
|
encodedPayload,
|
||||||
|
ContractJson.Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is null
|
||||||
|
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||||
|
|| !string.Equals(payload.ListingId, listingId.ToString(), StringComparison.Ordinal)
|
||||||
|
|| !JoinAttemptId.TryParse(payload.AfterAttemptId, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
after = attemptId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _protector.Dispose();
|
||||||
|
|
||||||
|
public override string ToString() => "[JoinAttemptCursorCodec: key and cursors redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptCursorPayload
|
||||||
|
{
|
||||||
|
[JsonRequired]
|
||||||
|
public string ListingId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string AfterAttemptId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public long ExpiresAtUnixSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
|
||||||
|
internal sealed record JoinAttemptServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ConnectionTicketGrant(string Ticket, DateTimeOffset ExpiresAt)
|
||||||
|
{
|
||||||
|
public override string ToString() => "[ConnectionTicketGrant: ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JoinAttemptService(
|
||||||
|
GamePolicyRegistry policies,
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
JoinAttemptCursorCodec cursors,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public string CreateAnonymousClientSubject(IPAddress remoteAddress)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(remoteAddress);
|
||||||
|
IPAddress normalized = remoteAddress.IsIPv4MappedToIPv6
|
||||||
|
? remoteAddress.MapToIPv4()
|
||||||
|
: remoteAddress;
|
||||||
|
return capabilities.DeriveOpaqueIdentifier("join-http-client", normalized.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<CreateJoinAttemptResponse> Create(
|
||||||
|
string clientSubject,
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
if (string.IsNullOrWhiteSpace(clientSubject))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A bounded client subject is required.", nameof(clientSubject));
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode validation = ValidateCreate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policies.TryGet(request.GameId, request.EnvironmentId, out GamePolicy? policy)
|
||||||
|
|| policy is null)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.AllowsProtocol(request.ProtocolVersion))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.IncompatibleProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
string requestFingerprint = ComputeRequestFingerprint(request);
|
||||||
|
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||||
|
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
|
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
|
||||||
|
"join-attempt-id",
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
string connectionTicket = NatIntroductionTokenCodec.Encode(
|
||||||
|
attemptId,
|
||||||
|
Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt));
|
||||||
|
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|
||||||
|
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(connectionTicket, out SecretFingerprint ticketFingerprint))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
|
||||||
|
}
|
||||||
|
|
||||||
|
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
|
||||||
|
"join-mediation-handle",
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
StoreResult<StoredJoinAttempt> created = store.CreateJoinAttempt(new()
|
||||||
|
{
|
||||||
|
IdempotencyOwner = clientSubject,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
RequestFingerprint = requestFingerprint,
|
||||||
|
ClientSubject = clientSubject,
|
||||||
|
AttemptId = attemptId,
|
||||||
|
MediationHandle = mediationHandle,
|
||||||
|
Scope = new(request.GameId, request.EnvironmentId),
|
||||||
|
ListingId = request.ListingId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
HostCapabilityFingerprint = hostFingerprint,
|
||||||
|
ClientCapabilityFingerprint = clientFingerprint,
|
||||||
|
ConnectionTicketFingerprint = ticketFingerprint,
|
||||||
|
CapabilityDerivationSalt = derivationSalt,
|
||||||
|
ScopeAttemptLimit = policy.MaxActiveJoinAttempts,
|
||||||
|
}, cancellationToken);
|
||||||
|
if (!created.Succeeded || created.Value is null)
|
||||||
|
{
|
||||||
|
return new(created.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredJoinAttempt persisted = created.Value;
|
||||||
|
clientCapability = Derive(
|
||||||
|
"join-client-punch",
|
||||||
|
persisted.ClientSubject,
|
||||||
|
persisted.IdempotencyKey,
|
||||||
|
persisted.RequestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
if (!capabilities.TryFingerprint(clientCapability, out SecretFingerprint persistedFingerprint)
|
||||||
|
|| persistedFingerprint != persisted.ClientCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its client capability.");
|
||||||
|
}
|
||||||
|
return new(RendezvousErrorCode.None, new CreateJoinAttemptResponse
|
||||||
|
{
|
||||||
|
AttemptId = persisted.AttemptId,
|
||||||
|
MediationHandle = persisted.MediationHandle,
|
||||||
|
ClientPunchCapability = clientCapability,
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
CreateConnectionTicket(persisted)),
|
||||||
|
ExpiresAt = persisted.ExpiresAt,
|
||||||
|
DedicatedFallback = StoredListing.CopyEndpoint(persisted.DedicatedFallback),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> BrowseForHost(
|
||||||
|
SessionListingId listingId,
|
||||||
|
int contractVersion,
|
||||||
|
string? leaseToken,
|
||||||
|
int pageSize,
|
||||||
|
string? cursor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||||
|
|| !ContractValidation.IsPageSizeValid(pageSize)
|
||||||
|
|| !ContractValidation.IsCursorValid(cursor)
|
||||||
|
|| !capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cursors.TryDecode(cursor, listingId, clock.UtcNow, out JoinAttemptId? after))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<IReadOnlyList<StoredJoinAttempt>> found = store.BrowseHostJoinAttempts(new(
|
||||||
|
listingId,
|
||||||
|
leaseFingerprint,
|
||||||
|
pageSize + 1,
|
||||||
|
after), cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return new(found.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasMore = found.Value.Count > pageSize;
|
||||||
|
StoredJoinAttempt[] page = found.Value.Take(pageSize).ToArray();
|
||||||
|
BrowseHostJoinAttemptsResponse response = new()
|
||||||
|
{
|
||||||
|
Items = page.Select(CreateHostAttempt).ToList(),
|
||||||
|
NextCursor = hasMore && page.Length > 0
|
||||||
|
? cursors.Encode(listingId, page[^1].AttemptId, clock.UtcNow)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||||
|
return ContractValidation.IsBrowserResponseSizeValid(encodedBytes)
|
||||||
|
? new(RendezvousErrorCode.None, response)
|
||||||
|
: new(RendezvousErrorCode.CapacityExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<bool> Cancel(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
string? clientPunchCapability,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsCapabilityValid(clientPunchCapability)
|
||||||
|
|| !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<bool> cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken);
|
||||||
|
return cancelled.Succeeded
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(cancelled.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JoinAttemptServiceResult<ConnectionTicketGrant> IssueConnectionTicket(
|
||||||
|
StoredJoinAttempt attempt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(attempt);
|
||||||
|
if (!attempt.IntroductionConsumed || attempt.IsCancelled)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
string ticket = CreateConnectionTicket(attempt);
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(ticket)
|
||||||
|
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|
||||||
|
|| fingerprint != attempt.ConnectionTicketFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its connection ticket.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new(ticket, attempt.ConnectionTicketExpiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private HostJoinAttempt CreateHostAttempt(StoredJoinAttempt attempt)
|
||||||
|
{
|
||||||
|
string capability = Derive(
|
||||||
|
"join-host-punch",
|
||||||
|
attempt.ClientSubject,
|
||||||
|
attempt.IdempotencyKey,
|
||||||
|
attempt.RequestFingerprint,
|
||||||
|
attempt.CapabilityDerivationSalt);
|
||||||
|
if (!ContractValidation.IsCapabilityValid(capability)
|
||||||
|
|| !capabilities.TryFingerprint(capability, out SecretFingerprint fingerprint)
|
||||||
|
|| fingerprint != attempt.HostCapabilityFingerprint)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Stored join state could not reproduce its host capability.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
AttemptId = attempt.AttemptId,
|
||||||
|
MediationHandle = attempt.MediationHandle,
|
||||||
|
HostPunchCapability = capability,
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
CreateConnectionTicket(attempt)),
|
||||||
|
IsCancelled = attempt.IsCancelled,
|
||||||
|
ExpiresAt = attempt.ExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CreateConnectionTicket(StoredJoinAttempt attempt) =>
|
||||||
|
NatIntroductionTokenCodec.Encode(
|
||||||
|
attempt.AttemptId,
|
||||||
|
Derive(
|
||||||
|
"connection-ticket",
|
||||||
|
attempt.ClientSubject,
|
||||||
|
attempt.IdempotencyKey,
|
||||||
|
attempt.RequestFingerprint,
|
||||||
|
attempt.CapabilityDerivationSalt));
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||||
|
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| request.ListingId.Value == Guid.Empty
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeRequestFingerprint(CreateJoinAttemptRequest request)
|
||||||
|
{
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(request, ContractJson.Options);
|
||||||
|
byte[] digest = SHA256.HashData(encoded);
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Derive(
|
||||||
|
string purpose,
|
||||||
|
string clientSubject,
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt) => Derive(
|
||||||
|
purpose,
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
|
||||||
|
private string Derive(
|
||||||
|
string purpose,
|
||||||
|
string clientSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt) => capabilities.DeriveCapability(
|
||||||
|
purpose,
|
||||||
|
clientSubject,
|
||||||
|
idempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
|
||||||
|
private static bool CredentialLengthsAreValid(
|
||||||
|
string hostCapability,
|
||||||
|
string clientCapability,
|
||||||
|
string ticket) =>
|
||||||
|
ContractValidation.IsCapabilityValid(hostCapability)
|
||||||
|
&& ContractValidation.IsCapabilityValid(clientCapability)
|
||||||
|
&& ContractValidation.IsConnectionTicketValid(ticket)
|
||||||
|
&& hostCapability.Length == ContractLimits.DerivedCredentialCharacters
|
||||||
|
&& clientCapability.Length == ContractLimits.DerivedCredentialCharacters
|
||||||
|
&& ticket.Length == ContractLimits.DerivedCredentialCharacters
|
||||||
|
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
|
||||||
|
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
|
||||||
|
|
||||||
|
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed class AuditOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Rendezvous:Audit";
|
||||||
|
|
||||||
|
[Range(100, 100_000)]
|
||||||
|
public int MaxEntries { get; set; } = 10_000;
|
||||||
|
|
||||||
|
[Range(1, 30)]
|
||||||
|
public int RetentionDays { get; set; } = 30;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed partial class AuditTrail
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly LinkedList<AuditEntry> _entries = [];
|
||||||
|
private readonly AuditOptions _options;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<AuditTrail> _logger;
|
||||||
|
private readonly RendezvousTelemetry _telemetry;
|
||||||
|
|
||||||
|
public AuditTrail(
|
||||||
|
IOptions<AuditOptions> options,
|
||||||
|
ILogger<AuditTrail> logger,
|
||||||
|
RendezvousTelemetry telemetry,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
_telemetry = telemetry;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Record(
|
||||||
|
string actorSubject,
|
||||||
|
string action,
|
||||||
|
string result,
|
||||||
|
string targetKind,
|
||||||
|
string targetIdentifier,
|
||||||
|
string correlationId)
|
||||||
|
{
|
||||||
|
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||||
|
AuditEntry entry = new(
|
||||||
|
now,
|
||||||
|
Fingerprint(actorSubject),
|
||||||
|
action,
|
||||||
|
result,
|
||||||
|
targetKind,
|
||||||
|
Fingerprint(targetIdentifier),
|
||||||
|
correlationId);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
PurgeExpired(now);
|
||||||
|
|
||||||
|
while (_entries.Count >= _options.MaxEntries)
|
||||||
|
{
|
||||||
|
_entries.RemoveFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
_entries.AddLast(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
_telemetry.RecordAudit(action, result);
|
||||||
|
LogOperatorAction(
|
||||||
|
_logger,
|
||||||
|
entry.Timestamp,
|
||||||
|
entry.ActorFingerprint,
|
||||||
|
action,
|
||||||
|
result,
|
||||||
|
targetKind,
|
||||||
|
entry.TargetFingerprint,
|
||||||
|
correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, long> GetAggregateCounts()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
PurgeExpired(_timeProvider.GetUtcNow());
|
||||||
|
return _entries
|
||||||
|
.GroupBy(static entry => $"{entry.Action}:{entry.Result}", StringComparer.Ordinal)
|
||||||
|
.ToDictionary(
|
||||||
|
static group => group.Key,
|
||||||
|
static group => (long)group.Count(),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IReadOnlyList<AuditEntry> GetEntriesForTests()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
PurgeExpired(_timeProvider.GetUtcNow());
|
||||||
|
return _entries.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PurgeExpired(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
DateTimeOffset oldest = now.AddDays(-_options.RetentionDays);
|
||||||
|
while (_entries.First is { Value.Timestamp: var timestamp }
|
||||||
|
&& timestamp < oldest)
|
||||||
|
{
|
||||||
|
_entries.RemoveFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Fingerprint(string value)
|
||||||
|
{
|
||||||
|
byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||||
|
return Convert.ToHexString(digest.AsSpan(0, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 100,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Operator audit at {Timestamp}: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}")]
|
||||||
|
private static partial void LogOperatorAction(
|
||||||
|
ILogger logger,
|
||||||
|
DateTimeOffset timestamp,
|
||||||
|
string actorFingerprint,
|
||||||
|
string action,
|
||||||
|
string result,
|
||||||
|
string targetKind,
|
||||||
|
string targetFingerprint,
|
||||||
|
string correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record AuditEntry(
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
string ActorFingerprint,
|
||||||
|
string Action,
|
||||||
|
string Result,
|
||||||
|
string TargetKind,
|
||||||
|
string TargetFingerprint,
|
||||||
|
string CorrelationId)
|
||||||
|
{
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[AuditEntry {Action}/{Result}; actor and target fingerprinted]";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal static class HealthEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapRendezvousHealthEndpoints(
|
||||||
|
this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
endpoints.MapGet(
|
||||||
|
"/health/live",
|
||||||
|
static () => Results.Ok(new HealthResponse { Status = "live" }))
|
||||||
|
.Produces<HealthResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.WithName("GetLiveness")
|
||||||
|
.WithTags("Health");
|
||||||
|
endpoints.MapGet(
|
||||||
|
"/health/ready",
|
||||||
|
static (RendezvousReadiness readiness) =>
|
||||||
|
!readiness.GetSnapshot().IsReady
|
||||||
|
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
: Results.Ok(new HealthResponse { Status = "ready" }))
|
||||||
|
.Produces<HealthResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("GetReadiness")
|
||||||
|
.WithTags("Health");
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed class RendezvousReadiness(
|
||||||
|
UdpMediatorService mediator,
|
||||||
|
ProvisioningReadiness provisioning,
|
||||||
|
IEphemeralRendezvousStore state,
|
||||||
|
IOptions<UdpMediatorOptions> udpOptions)
|
||||||
|
{
|
||||||
|
public ReadinessSnapshot GetSnapshot()
|
||||||
|
{
|
||||||
|
bool ipv6Required = !string.IsNullOrWhiteSpace(udpOptions.Value.Ipv6ListenAddress);
|
||||||
|
return new ReadinessSnapshot(
|
||||||
|
HttpListenerReady: true,
|
||||||
|
UdpIpv4ListenerReady: mediator.LocalEndpoint is not null,
|
||||||
|
UdpIpv6ListenerReady: !ipv6Required || mediator.LocalIpv6Endpoint is not null,
|
||||||
|
ProvisioningReady: provisioning.IsReady,
|
||||||
|
StoreAvailable: state.IsAvailable,
|
||||||
|
Draining: state.IsDraining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ReadinessSnapshot(
|
||||||
|
bool HttpListenerReady,
|
||||||
|
bool UdpIpv4ListenerReady,
|
||||||
|
bool UdpIpv6ListenerReady,
|
||||||
|
bool ProvisioningReady,
|
||||||
|
bool StoreAvailable,
|
||||||
|
bool Draining)
|
||||||
|
{
|
||||||
|
public bool IsReady => HttpListenerReady
|
||||||
|
&& UdpIpv4ListenerReady
|
||||||
|
&& UdpIpv6ListenerReady
|
||||||
|
&& ProvisioningReady
|
||||||
|
&& StoreAvailable
|
||||||
|
&& !Draining;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed class RendezvousTelemetry : IDisposable
|
||||||
|
{
|
||||||
|
public const string MeterName = "FinalFactory.Rendezvous";
|
||||||
|
public const string ActivitySourceName = "FinalFactory.Rendezvous.Server";
|
||||||
|
|
||||||
|
private readonly InMemoryEphemeralRendezvousStore _store;
|
||||||
|
private readonly Meter _meter = new(MeterName, "1.0.0");
|
||||||
|
private readonly ActivitySource _activities = new(ActivitySourceName, "1.0.0");
|
||||||
|
private readonly Counter<long> _httpRequests;
|
||||||
|
private readonly Histogram<double> _httpDuration;
|
||||||
|
private readonly Counter<long> _udpResults;
|
||||||
|
private readonly Histogram<double> _udpDuration;
|
||||||
|
private readonly Counter<long> _limiterDrops;
|
||||||
|
private readonly Counter<long> _auditEvents;
|
||||||
|
private readonly Counter<long> _connectionOutcomes;
|
||||||
|
private readonly Counter<long> _operatorAuthentication;
|
||||||
|
private readonly Histogram<double> _pairingLatency;
|
||||||
|
|
||||||
|
public RendezvousTelemetry(InMemoryEphemeralRendezvousStore store)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_httpRequests = _meter.CreateCounter<long>("rendezvous.http.requests");
|
||||||
|
_httpDuration = _meter.CreateHistogram<double>(
|
||||||
|
"rendezvous.http.duration",
|
||||||
|
"ms");
|
||||||
|
_udpResults = _meter.CreateCounter<long>("rendezvous.udp.results");
|
||||||
|
_udpDuration = _meter.CreateHistogram<double>(
|
||||||
|
"rendezvous.udp.duration",
|
||||||
|
"ms");
|
||||||
|
_limiterDrops = _meter.CreateCounter<long>("rendezvous.limiter.drops");
|
||||||
|
_auditEvents = _meter.CreateCounter<long>("rendezvous.audit.events");
|
||||||
|
_connectionOutcomes = _meter.CreateCounter<long>("rendezvous.connection.outcomes");
|
||||||
|
_operatorAuthentication = _meter.CreateCounter<long>("rendezvous.operator.authentication");
|
||||||
|
_pairingLatency = _meter.CreateHistogram<double>(
|
||||||
|
"rendezvous.pairing.latency",
|
||||||
|
"ms");
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.active_listings",
|
||||||
|
() => _store.GetMetricsSnapshot().ActiveListings);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.active_leases",
|
||||||
|
() => _store.GetMetricsSnapshot().ActiveListings);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.active_attempts",
|
||||||
|
() => _store.GetMetricsSnapshot().ActiveJoinAttempts);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.queue.depth",
|
||||||
|
() => _store.GetMetricsSnapshot().ActiveJoinAttempts);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.replay_markers",
|
||||||
|
() => _store.GetMetricsSnapshot().ReplayMarkers);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.available",
|
||||||
|
() => _store.GetMetricsSnapshot().IsAvailable ? 1 : 0);
|
||||||
|
_meter.CreateObservableCounter(
|
||||||
|
"rendezvous.store.expiry_churn",
|
||||||
|
() => _store.GetMetricsSnapshot().ExpiryChurn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) =>
|
||||||
|
_activities.StartActivity(name, kind);
|
||||||
|
|
||||||
|
public void RecordHttp(string operation, int statusCode, double elapsedMilliseconds)
|
||||||
|
{
|
||||||
|
TagList tags = new()
|
||||||
|
{
|
||||||
|
{ "operation", operation },
|
||||||
|
{ "status_code", statusCode },
|
||||||
|
};
|
||||||
|
_httpRequests.Add(1, tags);
|
||||||
|
_httpDuration.Record(elapsedMilliseconds, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordUdp(string operation, string result, double elapsedMilliseconds)
|
||||||
|
{
|
||||||
|
TagList tags = new()
|
||||||
|
{
|
||||||
|
{ "operation", operation },
|
||||||
|
{ "result", result },
|
||||||
|
};
|
||||||
|
_udpResults.Add(1, tags);
|
||||||
|
_udpDuration.Record(elapsedMilliseconds, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordLimiterDrop(string transport, string partition) =>
|
||||||
|
_limiterDrops.Add(1, new TagList
|
||||||
|
{
|
||||||
|
{ "transport", transport },
|
||||||
|
{ "partition", partition },
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordAudit(string action, string result) =>
|
||||||
|
_auditEvents.Add(1, new TagList
|
||||||
|
{
|
||||||
|
{ "action", action },
|
||||||
|
{ "result", result },
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordConnectionOutcome(string outcome, string elapsedBucket) =>
|
||||||
|
_connectionOutcomes.Add(1, new TagList
|
||||||
|
{
|
||||||
|
{ "outcome", outcome },
|
||||||
|
{ "elapsed_bucket", elapsedBucket },
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordOperatorAuthentication(string result) =>
|
||||||
|
_operatorAuthentication.Add(1, new TagList
|
||||||
|
{
|
||||||
|
{ "result", result },
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordPairingLatency(double elapsedMilliseconds) =>
|
||||||
|
_pairingLatency.Record(elapsedMilliseconds);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_activities.Dispose();
|
||||||
|
_meter.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed class TelemetryMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
RendezvousTelemetry telemetry)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
string operation = context.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()
|
||||||
|
?.EndpointName ?? "Unmatched";
|
||||||
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
using Activity? activity = telemetry.StartActivity(
|
||||||
|
$"HTTP {operation}",
|
||||||
|
ActivityKind.Server);
|
||||||
|
string correlationId = activity?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
|
||||||
|
context.Response.Headers["X-Rendezvous-Correlation-ID"] = correlationId;
|
||||||
|
activity?.SetTag("rendezvous.operation", operation);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await next(context).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
telemetry.RecordHttp(
|
||||||
|
operation,
|
||||||
|
context.Response.StatusCode,
|
||||||
|
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Operations;
|
||||||
|
|
||||||
|
internal static class OperatorEndpoints
|
||||||
|
{
|
||||||
|
private const string CorrelationHeader = "X-Rendezvous-Correlation-ID";
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapOperatorEndpoints(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
RouteGroupBuilder group = endpoints.MapGroup("/v1/operator").WithTags("Operator");
|
||||||
|
group.MapGet("/status", GetStatus)
|
||||||
|
.Produces<OperatorStatusResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.WithName("GetOperatorStatus");
|
||||||
|
group.MapPost("/listings/revoke", RevokeListing)
|
||||||
|
.Accepts<RevokeListingRequest>("application/json")
|
||||||
|
.Produces<OperatorActionResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("RevokeOperatorListing");
|
||||||
|
group.MapPost("/principals/revoke", RevokePrincipal)
|
||||||
|
.Accepts<RevokePrincipalRequest>("application/json")
|
||||||
|
.Produces<OperatorActionResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("RevokeOperatorPrincipal");
|
||||||
|
group.MapPost("/keys/revoke", RevokeSigningKey)
|
||||||
|
.Accepts<RevokeSigningKeyRequest>("application/json")
|
||||||
|
.Produces<OperatorActionResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.WithName("RevokeOperatorSigningKey");
|
||||||
|
group.MapPost("/drain", BeginDrain)
|
||||||
|
.Accepts<BeginDrainRequest>("application/json")
|
||||||
|
.Produces<OperatorActionResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.WithName("BeginOperatorDrain");
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult GetStatus(
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.ReadPolicy,
|
||||||
|
"inspect-status",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperatorStatusResponse response = service.GetStatus();
|
||||||
|
audit.Record(
|
||||||
|
principal!.Subject,
|
||||||
|
"inspect-status",
|
||||||
|
"succeeded",
|
||||||
|
"service",
|
||||||
|
"rendezvous",
|
||||||
|
Correlation(context));
|
||||||
|
return Results.Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RevokeListing(
|
||||||
|
[FromBody] RevokeListingRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.RevokePublisher,
|
||||||
|
"revoke-listing",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool valid = SessionListingId.TryParse(request.ListingId, out SessionListingId listingId);
|
||||||
|
if (!valid || !string.Equals(request.ListingId, request.ConfirmListingId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
AuditRejected(audit, principal!, "revoke-listing", "listing", request.ListingId, context);
|
||||||
|
return BadRequest("A valid listing ID and an exact repeated confirmation are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<bool> result = service.RevokeListing(listingId, cancellationToken);
|
||||||
|
return StoreActionResult(
|
||||||
|
result.Code,
|
||||||
|
audit,
|
||||||
|
principal!,
|
||||||
|
"revoke-listing",
|
||||||
|
"listing",
|
||||||
|
request.ListingId,
|
||||||
|
context,
|
||||||
|
affectedResources: result.Succeeded ? 1 : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RevokePrincipal(
|
||||||
|
[FromBody] RevokePrincipalRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.RevokePublisher,
|
||||||
|
"revoke-principal",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool safeSubject = request.Subject is { Length: > 0 and <= 128 }
|
||||||
|
&& request.Subject.All(static character => character is >= '!' and <= '~');
|
||||||
|
if (!safeSubject
|
||||||
|
|| !string.Equals(request.Subject, request.ConfirmSubject, StringComparison.Ordinal)
|
||||||
|
|| request.LifetimeSeconds is < 1 or > 600)
|
||||||
|
{
|
||||||
|
AuditRejected(audit, principal!, "revoke-principal", "principal", request.Subject, context);
|
||||||
|
return BadRequest("A valid subject, exact repeated confirmation, and 1-600 second lifetime are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<int> result = service.RevokePrincipal(
|
||||||
|
request.Subject,
|
||||||
|
TimeSpan.FromSeconds(request.LifetimeSeconds),
|
||||||
|
cancellationToken);
|
||||||
|
return StoreActionResult(
|
||||||
|
result.Code,
|
||||||
|
audit,
|
||||||
|
principal!,
|
||||||
|
"revoke-principal",
|
||||||
|
"principal",
|
||||||
|
request.Subject,
|
||||||
|
context,
|
||||||
|
result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RevokeSigningKey(
|
||||||
|
[FromBody] RevokeSigningKeyRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.RotateKeys,
|
||||||
|
"revoke-signing-key",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool safeKeyId = request.KeyId is { Length: > 0 and <= 64 }
|
||||||
|
&& request.KeyId.All(static character => character is
|
||||||
|
>= 'A' and <= 'Z'
|
||||||
|
or >= 'a' and <= 'z'
|
||||||
|
or >= '0' and <= '9'
|
||||||
|
or '-'
|
||||||
|
or '_');
|
||||||
|
if (!safeKeyId || !string.Equals(request.KeyId, request.ConfirmKeyId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
AuditRejected(audit, principal!, "revoke-signing-key", "signing-key", request.KeyId, context);
|
||||||
|
return BadRequest("A valid key ID and an exact repeated confirmation are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool revoked = service.RevokeSigningKey(request.KeyId);
|
||||||
|
string result = revoked ? "succeeded" : "not-found";
|
||||||
|
audit.Record(
|
||||||
|
principal!.Subject,
|
||||||
|
"revoke-signing-key",
|
||||||
|
result,
|
||||||
|
"signing-key",
|
||||||
|
request.KeyId,
|
||||||
|
Correlation(context));
|
||||||
|
return revoked
|
||||||
|
? Results.Ok(new OperatorActionResponse { Status = "completed" })
|
||||||
|
: Error(RendezvousErrorCode.NotFound, "The requested resource was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult BeginDrain(
|
||||||
|
[FromBody] BeginDrainRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.ManagePolicy,
|
||||||
|
"begin-drain",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(request.Confirmation, "DRAIN", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
AuditRejected(audit, principal!, "begin-drain", "service", "rendezvous", context);
|
||||||
|
return BadRequest("The confirmation value must be exactly 'DRAIN'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
service.BeginDrain(cancellationToken);
|
||||||
|
audit.Record(
|
||||||
|
principal!.Subject,
|
||||||
|
"begin-drain",
|
||||||
|
"succeeded",
|
||||||
|
"service",
|
||||||
|
"rendezvous",
|
||||||
|
Correlation(context));
|
||||||
|
return Results.Ok(new OperatorActionResponse { Status = "draining" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryAuthorize(
|
||||||
|
string? authorization,
|
||||||
|
OperatorPermission requiredPermission,
|
||||||
|
string operation,
|
||||||
|
PrincipalCredentialService credentials,
|
||||||
|
IWallClock clock,
|
||||||
|
AuditTrail audit,
|
||||||
|
RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure)
|
||||||
|
{
|
||||||
|
principal = null;
|
||||||
|
failure = null;
|
||||||
|
const string prefix = "Bearer ";
|
||||||
|
if (authorization is null
|
||||||
|
|| !authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
telemetry.RecordOperatorAuthentication("rejected");
|
||||||
|
failure = AuthenticationRequired(context);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CredentialValidationResult validation = credentials.Validate(
|
||||||
|
authorization[prefix.Length..],
|
||||||
|
clock.UtcNow);
|
||||||
|
if (!validation.IsValid || validation.Principal is not OperatorPrincipal candidate)
|
||||||
|
{
|
||||||
|
telemetry.RecordOperatorAuthentication("rejected");
|
||||||
|
failure = AuthenticationRequired(context);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidate.Permissions.Contains(requiredPermission))
|
||||||
|
{
|
||||||
|
telemetry.RecordOperatorAuthentication("forbidden");
|
||||||
|
audit.Record(
|
||||||
|
candidate.Subject,
|
||||||
|
operation,
|
||||||
|
"forbidden",
|
||||||
|
"operator-operation",
|
||||||
|
operation,
|
||||||
|
Correlation(context));
|
||||||
|
failure = Error(RendezvousErrorCode.Forbidden, "The operator is not authorized for this operation.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
telemetry.RecordOperatorAuthentication("accepted");
|
||||||
|
principal = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult StoreActionResult(
|
||||||
|
StoreResultCode code,
|
||||||
|
AuditTrail audit,
|
||||||
|
OperatorPrincipal principal,
|
||||||
|
string action,
|
||||||
|
string targetKind,
|
||||||
|
string targetIdentifier,
|
||||||
|
HttpContext context,
|
||||||
|
int? affectedResources)
|
||||||
|
{
|
||||||
|
string auditResult = code == StoreResultCode.Success
|
||||||
|
? "succeeded"
|
||||||
|
: code.ToString().ToLowerInvariant();
|
||||||
|
audit.Record(
|
||||||
|
principal.Subject,
|
||||||
|
action,
|
||||||
|
auditResult,
|
||||||
|
targetKind,
|
||||||
|
targetIdentifier,
|
||||||
|
Correlation(context));
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
StoreResultCode.Success => Results.Ok(new OperatorActionResponse
|
||||||
|
{
|
||||||
|
Status = "completed",
|
||||||
|
AffectedResources = affectedResources,
|
||||||
|
}),
|
||||||
|
StoreResultCode.NotFound => Error(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
"The requested resource was not found."),
|
||||||
|
StoreResultCode.CapacityExceeded => Error(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
"The operation could not be retained within the configured capacity."),
|
||||||
|
StoreResultCode.ServiceUnavailable or StoreResultCode.Draining => Error(
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
"The service is not available for this operation."),
|
||||||
|
_ => Error(RendezvousErrorCode.Conflict, "The operation could not be completed."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AuditRejected(
|
||||||
|
AuditTrail audit,
|
||||||
|
OperatorPrincipal principal,
|
||||||
|
string action,
|
||||||
|
string targetKind,
|
||||||
|
string? targetIdentifier,
|
||||||
|
HttpContext context) => audit.Record(
|
||||||
|
principal.Subject,
|
||||||
|
action,
|
||||||
|
"rejected",
|
||||||
|
targetKind,
|
||||||
|
targetIdentifier ?? string.Empty,
|
||||||
|
Correlation(context));
|
||||||
|
|
||||||
|
private static string Correlation(HttpContext context) =>
|
||||||
|
context.Response.Headers[CorrelationHeader].ToString() is { Length: > 0 } value
|
||||||
|
? value
|
||||||
|
: "unavailable";
|
||||||
|
|
||||||
|
private static IResult AuthenticationRequired(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"operator\"";
|
||||||
|
return Error(
|
||||||
|
RendezvousErrorCode.AuthenticationRequired,
|
||||||
|
"A valid operator bearer credential is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult BadRequest(string message) => Error(RendezvousErrorCode.InvalidRequest, message);
|
||||||
|
|
||||||
|
private static IResult Error(RendezvousErrorCode code, string message) => Results.Json(
|
||||||
|
new ApiError { Code = code, Message = message },
|
||||||
|
ContractJson.Options,
|
||||||
|
statusCode: code switch
|
||||||
|
{
|
||||||
|
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
|
||||||
|
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
|
||||||
|
RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound,
|
||||||
|
RendezvousErrorCode.CapacityExceeded => StatusCodes.Status429TooManyRequests,
|
||||||
|
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
|
||||||
|
RendezvousErrorCode.Conflict => StatusCodes.Status409Conflict,
|
||||||
|
_ => StatusCodes.Status400BadRequest,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.Server.Operations;
|
||||||
|
|
||||||
|
internal sealed record OperatorStatusResponse
|
||||||
|
{
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public required OperatorReadinessResponse Readiness { get; init; }
|
||||||
|
public required OperatorStoreResponse Store { get; init; }
|
||||||
|
public required IReadOnlyList<OperatorTenantResponse> Tenants { get; init; }
|
||||||
|
public required IReadOnlyList<OperatorSigningKeyResponse> SigningKeys { get; init; }
|
||||||
|
public required IReadOnlyDictionary<string, long> AuditCounts { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorReadinessResponse
|
||||||
|
{
|
||||||
|
public required bool HttpListener { get; init; }
|
||||||
|
public required bool UdpIpv4Listener { get; init; }
|
||||||
|
public required bool UdpIpv6Listener { get; init; }
|
||||||
|
public required bool Provisioning { get; init; }
|
||||||
|
public required bool Store { get; init; }
|
||||||
|
public required bool Draining { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorStoreResponse
|
||||||
|
{
|
||||||
|
public required int ActiveListings { get; init; }
|
||||||
|
public required int FreshPresenceBindings { get; init; }
|
||||||
|
public required int ActiveJoinAttempts { get; init; }
|
||||||
|
public required int RetainedOutcomeReports { get; init; }
|
||||||
|
public required int ReplayMarkers { get; init; }
|
||||||
|
public required int PrincipalRevocations { get; init; }
|
||||||
|
public required int IdempotencyEntries { get; init; }
|
||||||
|
public required long MaintenanceSweeps { get; init; }
|
||||||
|
public required long ExpiryChurn { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorTenantResponse
|
||||||
|
{
|
||||||
|
public required string GameId { get; init; }
|
||||||
|
public required string EnvironmentId { get; init; }
|
||||||
|
public required string Status { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorSigningKeyResponse
|
||||||
|
{
|
||||||
|
public required string KeyId { get; init; }
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public required DateTimeOffset SignUntil { get; init; }
|
||||||
|
public required DateTimeOffset VerifyUntil { get; init; }
|
||||||
|
public string? GameId { get; init; }
|
||||||
|
public string? EnvironmentId { get; init; }
|
||||||
|
public required IReadOnlyList<string> CredentialKinds { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorActionResponse
|
||||||
|
{
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public int? AffectedResources { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RevokeListingRequest
|
||||||
|
{
|
||||||
|
public required string ListingId { get; init; }
|
||||||
|
public required string ConfirmListingId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RevokePrincipalRequest
|
||||||
|
{
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
public required string ConfirmSubject { get; init; }
|
||||||
|
public required int LifetimeSeconds { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RevokeSigningKeyRequest
|
||||||
|
{
|
||||||
|
public required string KeyId { get; init; }
|
||||||
|
public required string ConfirmKeyId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record BeginDrainRequest
|
||||||
|
{
|
||||||
|
public required string Confirmation { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Operations;
|
||||||
|
|
||||||
|
internal sealed class OperatorService(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
ProvisioningRuntime provisioning,
|
||||||
|
RendezvousReadiness readiness,
|
||||||
|
AuditTrail audit,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public OperatorStatusResponse GetStatus()
|
||||||
|
{
|
||||||
|
ReadinessSnapshot readinessSnapshot = readiness.GetSnapshot();
|
||||||
|
EphemeralStoreSnapshot storeSnapshot = store.GetSnapshot();
|
||||||
|
return new OperatorStatusResponse
|
||||||
|
{
|
||||||
|
Status = readinessSnapshot.IsReady ? "ready" : "not-ready",
|
||||||
|
Readiness = new OperatorReadinessResponse
|
||||||
|
{
|
||||||
|
HttpListener = readinessSnapshot.HttpListenerReady,
|
||||||
|
UdpIpv4Listener = readinessSnapshot.UdpIpv4ListenerReady,
|
||||||
|
UdpIpv6Listener = readinessSnapshot.UdpIpv6ListenerReady,
|
||||||
|
Provisioning = readinessSnapshot.ProvisioningReady,
|
||||||
|
Store = readinessSnapshot.StoreAvailable,
|
||||||
|
Draining = readinessSnapshot.Draining,
|
||||||
|
},
|
||||||
|
Store = new OperatorStoreResponse
|
||||||
|
{
|
||||||
|
ActiveListings = storeSnapshot.ActiveListings,
|
||||||
|
FreshPresenceBindings = storeSnapshot.FreshPresenceBindings,
|
||||||
|
ActiveJoinAttempts = storeSnapshot.ActiveJoinAttempts,
|
||||||
|
RetainedOutcomeReports = storeSnapshot.RetainedOutcomeReports,
|
||||||
|
ReplayMarkers = storeSnapshot.ReplayMarkers,
|
||||||
|
PrincipalRevocations = storeSnapshot.PrincipalRevocations,
|
||||||
|
IdempotencyEntries = storeSnapshot.IdempotencyEntries,
|
||||||
|
MaintenanceSweeps = storeSnapshot.MaintenanceSweeps,
|
||||||
|
ExpiryChurn = storeSnapshot.ExpiryChurn,
|
||||||
|
},
|
||||||
|
Tenants = provisioning.Policies.EnabledPolicies
|
||||||
|
.OrderBy(static policy => policy.GameId.Value, StringComparer.Ordinal)
|
||||||
|
.ThenBy(static policy => policy.EnvironmentId.Value, StringComparer.Ordinal)
|
||||||
|
.Select(static policy => new OperatorTenantResponse
|
||||||
|
{
|
||||||
|
GameId = policy.GameId.Value,
|
||||||
|
EnvironmentId = policy.EnvironmentId.Value,
|
||||||
|
Status = "enabled",
|
||||||
|
})
|
||||||
|
.ToArray(),
|
||||||
|
SigningKeys = provisioning.SigningKeys.GetStatuses(clock.UtcNow)
|
||||||
|
.Select(static key => new OperatorSigningKeyResponse
|
||||||
|
{
|
||||||
|
KeyId = key.KeyId,
|
||||||
|
Status = key.Status,
|
||||||
|
SignUntil = key.SignUntil,
|
||||||
|
VerifyUntil = key.VerifyUntil,
|
||||||
|
GameId = key.GameId,
|
||||||
|
EnvironmentId = key.EnvironmentId,
|
||||||
|
CredentialKinds = key.CredentialKinds,
|
||||||
|
})
|
||||||
|
.ToArray(),
|
||||||
|
AuditCounts = audit.GetAggregateCounts(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoreResult<bool> RevokeListing(
|
||||||
|
SessionListingId listingId,
|
||||||
|
CancellationToken cancellationToken) => store.RevokeListing(listingId, cancellationToken);
|
||||||
|
|
||||||
|
public StoreResult<int> RevokePrincipal(
|
||||||
|
string subject,
|
||||||
|
TimeSpan lifetime,
|
||||||
|
CancellationToken cancellationToken) => store.RevokePrincipal(subject, lifetime, cancellationToken);
|
||||||
|
|
||||||
|
public bool RevokeSigningKey(string keyId) => provisioning.SigningKeys.Revoke(keyId);
|
||||||
|
|
||||||
|
public void BeginDrain(CancellationToken cancellationToken) => store.BeginDrain(cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,17 +1,31 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
using FinalFactory.Rendezvous.Server.Browser;
|
||||||
|
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||||
|
using FinalFactory.Rendezvous.Server.Deployment;
|
||||||
using FinalFactory.Rendezvous.Server.Http;
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.Operations;
|
||||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
using FinalFactory.Rendezvous.Server.Transport;
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.Logging.AddFilter(
|
||||||
|
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
|
||||||
|
LogLevel.None);
|
||||||
bool isOpenApiGeneration = string.Equals(
|
bool isOpenApiGeneration = string.Equals(
|
||||||
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
|
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
|
||||||
"GetDocument.Insider",
|
"GetDocument.Insider",
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
builder.Services.AddOpenApi("v1", static options =>
|
builder.Services.AddOpenApi("v1", static options =>
|
||||||
|
{
|
||||||
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
|
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
|
||||||
{
|
{
|
||||||
Type type = context.JsonTypeInfo.Type;
|
Type type = context.JsonTypeInfo.Type;
|
||||||
@@ -31,9 +45,231 @@ builder.Services.AddOpenApi("v1", static options =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}));
|
});
|
||||||
|
options.AddDocumentTransformer(static (document, context, cancellationToken) =>
|
||||||
|
{
|
||||||
|
const string schemeName = "PublisherBearer";
|
||||||
|
document.Components ??= new OpenApiComponents();
|
||||||
|
document.Components.SecuritySchemes ??=
|
||||||
|
new Dictionary<string, IOpenApiSecurityScheme>(StringComparer.Ordinal);
|
||||||
|
document.Components.SecuritySchemes[schemeName] = new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "bearer",
|
||||||
|
BearerFormat = "rv1 publisher credential",
|
||||||
|
Description = "Tenant-scoped publisher credential issued during game provisioning.",
|
||||||
|
};
|
||||||
|
const string attemptSchemeName = "JoinAttemptCapability";
|
||||||
|
document.Components.SecuritySchemes[attemptSchemeName] = new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = SecuritySchemeType.ApiKey,
|
||||||
|
Name = "X-Rendezvous-Client-Punch-Capability",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Description = "Attempt-scoped client capability returned only to the joining caller.",
|
||||||
|
};
|
||||||
|
const string operatorSchemeName = "OperatorBearer";
|
||||||
|
document.Components.SecuritySchemes[operatorSchemeName] = new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "bearer",
|
||||||
|
BearerFormat = "rv1 operator credential",
|
||||||
|
Description = "Operator-only credential with an explicit permission set.",
|
||||||
|
};
|
||||||
|
|
||||||
|
HashSet<string> securedOperations = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"RegisterSession",
|
||||||
|
"RenewSessionLease",
|
||||||
|
"UpdateSession",
|
||||||
|
"DeleteSession",
|
||||||
|
};
|
||||||
|
HashSet<string> operatorOperations = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"GetOperatorStatus",
|
||||||
|
"RevokeOperatorListing",
|
||||||
|
"RevokeOperatorPrincipal",
|
||||||
|
"RevokeOperatorSigningKey",
|
||||||
|
"BeginOperatorDrain",
|
||||||
|
};
|
||||||
|
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
|
||||||
|
OpenApiSecuritySchemeReference attemptReference = new(attemptSchemeName, document, null);
|
||||||
|
OpenApiSecuritySchemeReference operatorReference = new(operatorSchemeName, document, null);
|
||||||
|
foreach (OpenApiPathItem path in document.Paths.Values)
|
||||||
|
{
|
||||||
|
if (path.Operations is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
||||||
|
operation => securedOperations.Contains(operation.OperationId ?? string.Empty)))
|
||||||
|
{
|
||||||
|
operation.Security ??= [];
|
||||||
|
operation.Security.Add(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
[reference] = [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
||||||
|
operation => operation.OperationId is
|
||||||
|
"CancelJoinAttempt" or "ReportConnectionOutcome"))
|
||||||
|
{
|
||||||
|
operation.Security ??= [];
|
||||||
|
operation.Security.Add(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
[attemptReference] = [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
||||||
|
operation => operatorOperations.Contains(
|
||||||
|
operation.OperationId ?? string.Empty)))
|
||||||
|
{
|
||||||
|
operation.Security ??= [];
|
||||||
|
operation.Security.Add(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
[operatorReference] = [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (OpenApiOperation operation in path.Operations.Values)
|
||||||
|
{
|
||||||
|
if (operation.Responses is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((string status, IOpenApiResponse response) in operation.Responses)
|
||||||
|
{
|
||||||
|
if (response is not OpenApiResponse concreteResponse)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
concreteResponse.Headers ??=
|
||||||
|
new Dictionary<string, IOpenApiHeader>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
concreteResponse.Headers["X-Rendezvous-Correlation-ID"] = new OpenApiHeader
|
||||||
|
{
|
||||||
|
Description = "Safe request correlation identifier generated by the service.",
|
||||||
|
Schema = new OpenApiSchema
|
||||||
|
{
|
||||||
|
Type = JsonSchemaType.String,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (string.Equals(
|
||||||
|
status,
|
||||||
|
StatusCodes.Status429TooManyRequests.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
concreteResponse.Headers["Retry-After"] = new OpenApiHeader
|
||||||
|
{
|
||||||
|
Description = "Whole seconds before the caller should retry (1-60).",
|
||||||
|
Schema = new OpenApiSchema
|
||||||
|
{
|
||||||
|
Type = JsonSchemaType.Integer,
|
||||||
|
Format = "int32",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
});
|
||||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
ContractJson.Configure(options.SerializerOptions));
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
|
options.ThrowOnBadRequest = true);
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||||
|
builder.WebHost.ConfigureKestrel(static options =>
|
||||||
|
options.Limits.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes);
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddOptions<AbuseProtectionOptions>()
|
||||||
|
.BindConfiguration(AbuseProtectionOptions.SectionName)
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.Validate(
|
||||||
|
options => options.HttpOptionalRequestsPerWindow
|
||||||
|
< options.HttpGlobalRequestsPerWindow,
|
||||||
|
"The optional HTTP request budget must leave global capacity for lease operations.")
|
||||||
|
.Validate(
|
||||||
|
options => options.HttpOptionalConcurrency < options.HttpGlobalConcurrency,
|
||||||
|
"The optional HTTP concurrency budget must leave global capacity for lease operations.")
|
||||||
|
.Validate(
|
||||||
|
options => options.HttpOptionalIpPrefixRequestsPerWindow
|
||||||
|
< options.HttpIpPrefixRequestsPerWindow,
|
||||||
|
"The optional HTTP source budget must leave capacity for lease operations.")
|
||||||
|
.Validate(
|
||||||
|
options => options.HttpOptionalIpPrefixConcurrency
|
||||||
|
< options.HttpIpPrefixConcurrency,
|
||||||
|
"The optional HTTP source concurrency must leave capacity for lease operations.")
|
||||||
|
.Validate(
|
||||||
|
options => options.CriticalTrackedKeyReserve >= 16
|
||||||
|
&& options.UdpTrackedKeyLimit + options.CriticalTrackedKeyReserve
|
||||||
|
< options.MaxTrackedKeys,
|
||||||
|
"The tracked-key reserve must leave at least 16 keys for critical operations.")
|
||||||
|
.Validate(
|
||||||
|
options => options.TrustedProxyAddresses is { Length: <= 32 } addresses
|
||||||
|
&& addresses.All(
|
||||||
|
static value => IPAddress.TryParse(value, out _)),
|
||||||
|
"Trusted proxy addresses must contain at most 32 literal IP addresses.")
|
||||||
|
.Validate(
|
||||||
|
options => options.OperatorAllowedAddresses is { Length: <= 32 } addresses
|
||||||
|
&& addresses.All(
|
||||||
|
static value => IPAddress.TryParse(value, out _)),
|
||||||
|
"Operator allowed addresses must contain at most 32 literal IP addresses.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
builder.Services.AddSingleton<AbuseProtectionService>();
|
||||||
|
builder.Services
|
||||||
|
.AddOptions<AuditOptions>()
|
||||||
|
.BindConfiguration(AuditOptions.SectionName)
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.ValidateOnStart();
|
||||||
|
AbuseProtectionOptions configuredAbuseProtection = builder.Configuration
|
||||||
|
.GetSection(AbuseProtectionOptions.SectionName)
|
||||||
|
.Get<AbuseProtectionOptions>() ?? new AbuseProtectionOptions();
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
TrustedProxyForwarding.Configure(options, configuredAbuseProtection));
|
||||||
|
|
||||||
|
DeploymentOptions deploymentOptions = builder.Configuration
|
||||||
|
.GetSection(DeploymentOptions.SectionName)
|
||||||
|
.Get<DeploymentOptions>() ?? new DeploymentOptions();
|
||||||
|
if (!builder.Environment.IsDevelopment() && !isOpenApiGeneration)
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> deploymentErrors = deploymentOptions.ValidateProduction(
|
||||||
|
configuredAbuseProtection,
|
||||||
|
builder.Configuration["AllowedHosts"]);
|
||||||
|
if (deploymentErrors.Count > 0)
|
||||||
|
{
|
||||||
|
throw new DeploymentConfigurationException(deploymentErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(deploymentOptions));
|
||||||
|
builder.Services.Configure<HostOptions>(options =>
|
||||||
|
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
|
||||||
|
|
||||||
|
SystemRendezvousClock rendezvousClock = new();
|
||||||
|
EphemeralStoreOptions stateOptions = new()
|
||||||
|
{
|
||||||
|
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
|
||||||
|
};
|
||||||
|
InMemoryEphemeralRendezvousStore stateStore = new(
|
||||||
|
stateOptions,
|
||||||
|
rendezvousClock,
|
||||||
|
rendezvousClock);
|
||||||
|
builder.Services.AddSingleton(stateStore);
|
||||||
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
||||||
|
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
|
||||||
|
builder.Services.AddSingleton<RendezvousTelemetry>();
|
||||||
|
builder.Services.AddSingleton<AuditTrail>();
|
||||||
|
builder.Services.AddSingleton<RendezvousReadiness>();
|
||||||
|
|
||||||
if (isOpenApiGeneration)
|
if (isOpenApiGeneration)
|
||||||
{
|
{
|
||||||
@@ -55,6 +291,18 @@ else
|
|||||||
builder.Services.AddSingleton(provisioning.Policies);
|
builder.Services.AddSingleton(provisioning.Policies);
|
||||||
builder.Services.AddSingleton(provisioning.Credentials);
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
EphemeralCapabilityIssuer sessionCapabilities = new();
|
||||||
|
builder.Services.AddSingleton(sessionCapabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(sessionCapabilities);
|
||||||
|
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||||
|
builder.Services.AddSingleton<SessionLeaseService>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<SessionBrowserService>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
|
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
||||||
|
builder.Services.AddSingleton<ConnectionOutcomeService>();
|
||||||
|
builder.Services.AddSingleton<OperatorService>();
|
||||||
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,36 +311,39 @@ builder.Services
|
|||||||
.BindConfiguration(UdpMediatorOptions.SectionName)
|
.BindConfiguration(UdpMediatorOptions.SectionName)
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.Validate(
|
.Validate(
|
||||||
options => IPAddress.TryParse(options.ListenAddress, out _),
|
options => IPAddress.TryParse(options.ListenAddress, out IPAddress? address)
|
||||||
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
|
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork,
|
||||||
|
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IPv4 address.")
|
||||||
|
.Validate(
|
||||||
|
options => string.IsNullOrWhiteSpace(options.Ipv6ListenAddress)
|
||||||
|
|| (IPAddress.TryParse(options.Ipv6ListenAddress, out IPAddress? address)
|
||||||
|
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6),
|
||||||
|
$"{UdpMediatorOptions.SectionName}:Ipv6ListenAddress must be an IPv6 address when configured.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
builder.Services.AddSingleton<UdpMediatorService>();
|
builder.Services.AddSingleton<UdpMediatorService>();
|
||||||
if (!isOpenApiGeneration)
|
if (!isOpenApiGeneration)
|
||||||
{
|
{
|
||||||
|
builder.Services.AddSingleton<NatMediationProcessor>();
|
||||||
builder.Services.AddHostedService(static services =>
|
builder.Services.AddHostedService(static services =>
|
||||||
services.GetRequiredService<UdpMediatorService>());
|
services.GetRequiredService<UdpMediatorService>());
|
||||||
|
// Hosted services stop in reverse registration order. Drain must complete while
|
||||||
|
// Kestrel and the UDP mediator are still able to finish bounded in-flight work.
|
||||||
|
builder.Services.AddHostedService<GracefulDrainService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
|
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
|
||||||
|
{
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
}
|
||||||
|
app.UseMiddleware<TelemetryMiddleware>();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
app.MapRendezvousContractEndpoints();
|
app.MapRendezvousContractEndpoints();
|
||||||
app.MapGet(
|
app.MapOperatorEndpoints();
|
||||||
"/health/live",
|
app.MapRendezvousHealthEndpoints();
|
||||||
static () => Results.Ok(new HealthResponse { Status = "live" }))
|
|
||||||
.Produces<HealthResponse>()
|
|
||||||
.WithName("GetLiveness")
|
|
||||||
.WithTags("Health");
|
|
||||||
app.MapGet(
|
|
||||||
"/health/ready",
|
|
||||||
static (UdpMediatorService mediator, ProvisioningReadiness provisioning) =>
|
|
||||||
mediator.LocalEndpoint is null || !provisioning.IsReady
|
|
||||||
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
|
|
||||||
: Results.Ok(new HealthResponse { Status = "ready" }))
|
|
||||||
.Produces<HealthResponse>()
|
|
||||||
.Produces(StatusCodes.Status503ServiceUnavailable)
|
|
||||||
.WithName("GetReadiness")
|
|
||||||
.WithTags("Health");
|
|
||||||
|
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
||||||
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Capacity")]
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ internal sealed class PrincipalCredentialService
|
|||||||
|
|
||||||
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
|
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
|
||||||
{
|
{
|
||||||
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
|
return CredentialValidationResult.Invalid(CredentialValidationError.SignatureInvalid);
|
||||||
}
|
}
|
||||||
|
|
||||||
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
|
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
|
||||||
@@ -441,7 +441,14 @@ internal static class Base64Url
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
bytes = Convert.FromBase64String(padded);
|
bytes = Convert.FromBase64String(padded);
|
||||||
return true;
|
if (string.Equals(Encode(bytes), value, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
bytes = [];
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,18 +40,32 @@ internal sealed class SecretMaterial : IDisposable
|
|||||||
|
|
||||||
internal sealed class EnvironmentSecretProvider : ISecretProvider
|
internal sealed class EnvironmentSecretProvider : ISecretProvider
|
||||||
{
|
{
|
||||||
private const string Prefix = "env:";
|
private const string EnvironmentPrefix = "env:";
|
||||||
|
private const string FilePrefix = "file:";
|
||||||
|
private const int MaximumSecretBytes = 4096;
|
||||||
|
|
||||||
public bool TryGetSecret(string reference, out SecretMaterial? secret)
|
public bool TryGetSecret(string reference, out SecretMaterial? secret)
|
||||||
{
|
{
|
||||||
secret = null;
|
secret = null;
|
||||||
if (!reference.StartsWith(Prefix, StringComparison.Ordinal)
|
if (reference.StartsWith(EnvironmentPrefix, StringComparison.Ordinal)
|
||||||
|| reference.Length == Prefix.Length)
|
&& reference.Length > EnvironmentPrefix.Length)
|
||||||
{
|
{
|
||||||
return false;
|
return TryGetEnvironmentSecret(reference[EnvironmentPrefix.Length..], out secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
string? encoded = Environment.GetEnvironmentVariable(reference[Prefix.Length..]);
|
if (reference.StartsWith(FilePrefix, StringComparison.Ordinal)
|
||||||
|
&& reference.Length > FilePrefix.Length)
|
||||||
|
{
|
||||||
|
return TryGetFileSecret(reference[FilePrefix.Length..], out secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetEnvironmentSecret(string variableName, out SecretMaterial? secret)
|
||||||
|
{
|
||||||
|
secret = null;
|
||||||
|
string? encoded = Environment.GetEnvironmentVariable(variableName);
|
||||||
if (string.IsNullOrEmpty(encoded))
|
if (string.IsNullOrEmpty(encoded))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -60,6 +74,12 @@ internal sealed class EnvironmentSecretProvider : ISecretProvider
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
byte[] bytes = Convert.FromBase64String(encoded);
|
byte[] bytes = Convert.FromBase64String(encoded);
|
||||||
|
if (bytes.Length is 0 or > MaximumSecretBytes)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
secret = new SecretMaterial(bytes);
|
secret = new SecretMaterial(bytes);
|
||||||
CryptographicOperations.ZeroMemory(bytes);
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
return true;
|
return true;
|
||||||
@@ -69,6 +89,47 @@ internal sealed class EnvironmentSecretProvider : ISecretProvider
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryGetFileSecret(string path, out SecretMaterial? secret)
|
||||||
|
{
|
||||||
|
secret = null;
|
||||||
|
byte[]? bytes = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FileInfo file = new(path);
|
||||||
|
if (!file.Exists
|
||||||
|
|| !Path.IsPathFullyQualified(path)
|
||||||
|
|| file.LinkTarget is not null
|
||||||
|
|| file.Length is <= 0 or > MaximumSecretBytes)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes = File.ReadAllBytes(path);
|
||||||
|
if (bytes.Length is 0 or > MaximumSecretBytes)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
secret = new SecretMaterial(bytes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or ArgumentException
|
||||||
|
or NotSupportedException
|
||||||
|
or System.Security.SecurityException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (bytes is not null)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class EphemeralDevelopmentSecretProvider : ISecretProvider, IDisposable
|
internal sealed class EphemeralDevelopmentSecretProvider : ISecretProvider, IDisposable
|
||||||
|
|||||||
@@ -142,8 +142,36 @@ internal sealed class SigningKeyRing : IDisposable
|
|||||||
return VerificationKeyLookup.Available;
|
return VerificationKeyLookup.Available;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Revoke(string keyId) =>
|
public bool Revoke(string keyId)
|
||||||
_keys.ContainsKey(keyId) && _runtimeRevocations.TryAdd(keyId, 0);
|
{
|
||||||
|
if (!_keys.ContainsKey(keyId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_runtimeRevocations.TryAdd(keyId, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SigningKeyStatus> GetStatuses(DateTimeOffset now) => _keys.Values
|
||||||
|
.OrderBy(static key => key.KeyId, StringComparer.Ordinal)
|
||||||
|
.Select(key => new SigningKeyStatus(
|
||||||
|
key.KeyId,
|
||||||
|
IsRevoked(key)
|
||||||
|
? "revoked"
|
||||||
|
: now < key.NotBefore
|
||||||
|
? "not-yet-valid"
|
||||||
|
: now < key.SignUntil
|
||||||
|
? "signing"
|
||||||
|
: now < key.VerifyUntil
|
||||||
|
? "verify-only"
|
||||||
|
: "retired",
|
||||||
|
key.SignUntil,
|
||||||
|
key.VerifyUntil,
|
||||||
|
key.GameId,
|
||||||
|
key.EnvironmentId,
|
||||||
|
key.CredentialKinds.Select(static kind => kind.ToString()).Order().ToArray()))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
@@ -210,6 +238,15 @@ internal sealed class SigningKeyRing : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record SigningKeyStatus(
|
||||||
|
string KeyId,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset SignUntil,
|
||||||
|
DateTimeOffset VerifyUntil,
|
||||||
|
string? GameId,
|
||||||
|
string? EnvironmentId,
|
||||||
|
IReadOnlyList<string> CredentialKinds);
|
||||||
|
|
||||||
internal sealed class SigningKey : IDisposable
|
internal sealed class SigningKey : IDisposable
|
||||||
{
|
{
|
||||||
private byte[]? _material;
|
private byte[]? _material;
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
|
||||||
|
internal interface ISessionCapabilityService
|
||||||
|
{
|
||||||
|
string CreateDerivationSalt();
|
||||||
|
string DeriveCapability(
|
||||||
|
string purpose,
|
||||||
|
string ownerSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt);
|
||||||
|
Guid DeriveGuid(
|
||||||
|
string purpose,
|
||||||
|
string ownerSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt);
|
||||||
|
string DeriveOpaqueIdentifier(string purpose, string value);
|
||||||
|
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public string CreateDerivationSalt()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
byte[] salt = RandomNumberGenerator.GetBytes(32);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(salt);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(salt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DeriveCapability(
|
||||||
|
string purpose,
|
||||||
|
string ownerSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt)
|
||||||
|
{
|
||||||
|
byte[] digest = Derive(
|
||||||
|
purpose,
|
||||||
|
ownerSubject,
|
||||||
|
idempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid DeriveGuid(
|
||||||
|
string purpose,
|
||||||
|
string ownerSubject,
|
||||||
|
string idempotencyKey,
|
||||||
|
string requestFingerprint,
|
||||||
|
string derivationSalt)
|
||||||
|
{
|
||||||
|
byte[] digest = Derive(
|
||||||
|
purpose,
|
||||||
|
ownerSubject,
|
||||||
|
idempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Span<byte> guidBytes = digest.AsSpan(0, 16);
|
||||||
|
guidBytes[7] = (byte)((guidBytes[7] & 0x0f) | 0x80);
|
||||||
|
guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80);
|
||||||
|
return new Guid(guidBytes);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DeriveOpaqueIdentifier(string purpose, string value)
|
||||||
|
{
|
||||||
|
byte[] digest = Derive(purpose, value);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Encode(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
|
||||||
|
{
|
||||||
|
fingerprint = default;
|
||||||
|
if (_disposed
|
||||||
|
|| capability is null
|
||||||
|
|| capability.Length != 43
|
||||||
|
|| capability.Any(static character =>
|
||||||
|
character is not (>= 'A' and <= 'Z')
|
||||||
|
and not (>= 'a' and <= 'z')
|
||||||
|
and not (>= '0' and <= '9')
|
||||||
|
and not '-'
|
||||||
|
and not '_'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] digest = Derive("fingerprint", capability);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fingerprint = new SecretFingerprint(Encode(digest));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
CryptographicOperations.ZeroMemory(_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => "[EphemeralCapabilityIssuer: key and capabilities redacted]";
|
||||||
|
|
||||||
|
private byte[] Derive(params string[] segments)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
using IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, _key);
|
||||||
|
Span<byte> length = stackalloc byte[sizeof(int)];
|
||||||
|
foreach (string segment in segments)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(segment);
|
||||||
|
byte[] encoded = Encoding.UTF8.GetBytes(segment);
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(length, encoded.Length);
|
||||||
|
hmac.AppendData(length);
|
||||||
|
hmac.AppendData(encoded);
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hmac.GetHashAndReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||||
|
.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
|
||||||
|
internal sealed record SessionLeaseTiming(
|
||||||
|
int LeaseRenewAfterSeconds,
|
||||||
|
int HostPresenceRefreshAfterSeconds)
|
||||||
|
{
|
||||||
|
public static SessionLeaseTiming From(EphemeralStoreOptions options) => new(
|
||||||
|
Math.Max(1, (int)(options.LeaseLifetime.TotalSeconds / 2)),
|
||||||
|
Math.Max(1, (int)(options.PresenceLifetime.TotalSeconds / 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SessionServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SessionLeaseService(
|
||||||
|
PublisherAuthorizationService authorization,
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
SessionLeaseTiming timing,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public SessionServiceResult<RegisterSessionResponse> Register(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
RegisterSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateRegistration(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
PublisherAuthorizationResult authorized = authorization.Authorize(
|
||||||
|
principal,
|
||||||
|
request.GameId,
|
||||||
|
request.EnvironmentId,
|
||||||
|
request.RegionId,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
request.Visibility,
|
||||||
|
request.Metadata,
|
||||||
|
clock.UtcNow);
|
||||||
|
if (!authorized.IsAllowed || authorized.Context is null)
|
||||||
|
{
|
||||||
|
return new(MapAuthorization(authorized.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthorizedPublisherContext context = authorized.Context;
|
||||||
|
if (!IsFallbackAllowed(context.Policy, request.DedicatedFallback))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
string requestFingerprint = ComputeRegistrationFingerprint(request);
|
||||||
|
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||||
|
string leaseToken = capabilities.DeriveCapability(
|
||||||
|
"lease-token",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
string presenceCapability = capabilities.DeriveCapability(
|
||||||
|
"host-presence",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt);
|
||||||
|
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint)
|
||||||
|
|| !capabilities.TryFingerprint(presenceCapability, out SecretFingerprint presenceFingerprint))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Derived session capabilities could not be fingerprinted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionListingId listingId = new(capabilities.DeriveGuid(
|
||||||
|
"listing-id",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
LeaseId leaseId = new(capabilities.DeriveGuid(
|
||||||
|
"lease-id",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
MediationHandle presenceHandle = new(capabilities.DeriveGuid(
|
||||||
|
"presence-handle",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
int ownerLimit = context.TrustMode == PublisherTrustMode.AnonymousUnlisted
|
||||||
|
? context.Policy.MaxAnonymousListingsPerAddress
|
||||||
|
: context.Policy.MaxListingsPerPrincipal;
|
||||||
|
if (ownerLimit <= 0)
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.CapacityExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> created = store.CreateListing(new(
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
new ListingDefinition
|
||||||
|
{
|
||||||
|
ListingId = listingId,
|
||||||
|
LeaseId = leaseId,
|
||||||
|
Scope = new(context.GameId, context.EnvironmentId),
|
||||||
|
OwnerSubject = context.Subject,
|
||||||
|
RegionId = context.RegionId,
|
||||||
|
ProtocolVersion = context.ProtocolVersion,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Visibility = context.Visibility,
|
||||||
|
TrustMode = context.TrustMode,
|
||||||
|
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||||
|
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||||
|
Metadata = request.Metadata,
|
||||||
|
DedicatedFallback = request.DedicatedFallback,
|
||||||
|
LeaseFingerprint = leaseFingerprint,
|
||||||
|
HostPresenceHandle = presenceHandle,
|
||||||
|
HostPresenceFingerprint = presenceFingerprint,
|
||||||
|
CapabilityDerivationSalt = derivationSalt,
|
||||||
|
},
|
||||||
|
ownerLimit), cancellationToken);
|
||||||
|
if (!created.Succeeded || created.Value is null)
|
||||||
|
{
|
||||||
|
return new(created.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
ListingDefinition persisted = created.Value.Definition;
|
||||||
|
leaseToken = capabilities.DeriveCapability(
|
||||||
|
"lease-token",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
presenceCapability = capabilities.DeriveCapability(
|
||||||
|
"host-presence",
|
||||||
|
context.Subject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
persisted.CapabilityDerivationSalt);
|
||||||
|
|
||||||
|
return new(RendezvousErrorCode.None, new RegisterSessionResponse
|
||||||
|
{
|
||||||
|
ListingId = persisted.ListingId,
|
||||||
|
LeaseId = persisted.LeaseId,
|
||||||
|
LeaseToken = leaseToken,
|
||||||
|
HostPresenceHandle = persisted.HostPresenceHandle,
|
||||||
|
HostPresenceCapability = presenceCapability,
|
||||||
|
ExpiresAt = created.Value.LeaseExpiresAt,
|
||||||
|
LeaseRenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||||
|
HostPresenceRefreshAfterSeconds = timing.HostPresenceRefreshAfterSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionServiceResult<RenewLeaseResponse> Renew(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
RenewLeaseRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||||
|
principal,
|
||||||
|
listingId,
|
||||||
|
request.LeaseToken,
|
||||||
|
cancellationToken,
|
||||||
|
out StoredListing? listing);
|
||||||
|
if (lookup != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing ownedListing = listing!;
|
||||||
|
PublisherAuthorizationResult authorized = AuthorizeExisting(
|
||||||
|
principal,
|
||||||
|
ownedListing,
|
||||||
|
ownedListing.Definition.Metadata);
|
||||||
|
if (!authorized.IsAllowed)
|
||||||
|
{
|
||||||
|
return new(MapAuthorization(authorized.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||||
|
StoreResult<StoredListing> renewed = store.RenewLease(new(
|
||||||
|
listingId,
|
||||||
|
ownedListing.Definition.LeaseId,
|
||||||
|
fingerprint,
|
||||||
|
ownedListing.Definition.OwnerSubject,
|
||||||
|
ownedListing.Version), cancellationToken);
|
||||||
|
return renewed.Succeeded && renewed.Value is not null
|
||||||
|
? new(RendezvousErrorCode.None, new RenewLeaseResponse
|
||||||
|
{
|
||||||
|
ExpiresAt = renewed.Value.LeaseExpiresAt,
|
||||||
|
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||||
|
})
|
||||||
|
: new(renewed.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionServiceResult<bool> Update(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
UpdateSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateUpdate(request);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||||
|
principal,
|
||||||
|
listingId,
|
||||||
|
request.LeaseToken,
|
||||||
|
cancellationToken,
|
||||||
|
out StoredListing? listing);
|
||||||
|
if (lookup != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredListing ownedListing = listing!;
|
||||||
|
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
|
||||||
|
if (!authorized.IsAllowed || authorized.Context is null)
|
||||||
|
{
|
||||||
|
return new(MapAuthorization(authorized.Error));
|
||||||
|
}
|
||||||
|
if (!IsFallbackAllowed(authorized.Context.Policy, request.DedicatedFallback))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||||
|
StoreResult<StoredListing> updated = store.UpdateListing(new(
|
||||||
|
listingId,
|
||||||
|
ownedListing.Definition.LeaseId,
|
||||||
|
fingerprint,
|
||||||
|
ownedListing.Definition.OwnerSubject,
|
||||||
|
request.BuildVersion,
|
||||||
|
request.DisplayName,
|
||||||
|
request.Capacity.CurrentPlayers,
|
||||||
|
request.Capacity.MaximumPlayers,
|
||||||
|
request.Metadata,
|
||||||
|
request.DedicatedFallback), cancellationToken);
|
||||||
|
return updated.Succeeded
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(updated.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionServiceResult<bool> Delete(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
DeleteSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(principal);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||||
|
if (validation != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return new(validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (principal is not IPublisherPrincipal publisher
|
||||||
|
|| !capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return new(RendezvousErrorCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return found.Code == StoreResultCode.ServiceUnavailable
|
||||||
|
? new(RendezvousErrorCode.ServiceUnavailable)
|
||||||
|
: new(RendezvousErrorCode.None, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<bool> deleted = store.DeleteListing(new(
|
||||||
|
listingId,
|
||||||
|
found.Value.Definition.LeaseId,
|
||||||
|
fingerprint,
|
||||||
|
publisher.Subject), cancellationToken);
|
||||||
|
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
|
||||||
|
? new(RendezvousErrorCode.None, true)
|
||||||
|
: new(deleted.Code.ToContractError());
|
||||||
|
}
|
||||||
|
|
||||||
|
private RendezvousErrorCode GetAuthorizedListing(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
SessionListingId listingId,
|
||||||
|
string leaseToken,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
out StoredListing? listing)
|
||||||
|
{
|
||||||
|
listing = null;
|
||||||
|
if (principal is not IPublisherPrincipal publisher)
|
||||||
|
{
|
||||||
|
return RendezvousErrorCode.Forbidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return RendezvousErrorCode.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||||
|
if (!found.Succeeded || found.Value is null)
|
||||||
|
{
|
||||||
|
return found.Code.ToContractError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|
||||||
|
|| found.Value.Definition.LeaseFingerprint != fingerprint)
|
||||||
|
{
|
||||||
|
return RendezvousErrorCode.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
listing = found.Value;
|
||||||
|
return RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublisherAuthorizationResult AuthorizeExisting(
|
||||||
|
AuthenticatedPrincipal principal,
|
||||||
|
StoredListing listing,
|
||||||
|
IReadOnlyDictionary<string, string> metadata) => authorization.Authorize(
|
||||||
|
principal,
|
||||||
|
listing.Definition.Scope.GameId,
|
||||||
|
listing.Definition.Scope.EnvironmentId,
|
||||||
|
listing.Definition.RegionId,
|
||||||
|
listing.Definition.ProtocolVersion,
|
||||||
|
listing.Definition.Visibility,
|
||||||
|
metadata,
|
||||||
|
clock.UtcNow);
|
||||||
|
|
||||||
|
private static bool IsFallbackAllowed(GamePolicy policy, NetworkEndpoint? fallback) =>
|
||||||
|
fallback is null || policy.FallbackPolicy == FallbackPolicyMode.DedicatedEndpointAllowed;
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||||
|
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||||
|
|| string.IsNullOrEmpty(request.RegionId.Value)
|
||||||
|
|| request.ProtocolVersion == 0
|
||||||
|
|| !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|
||||||
|
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||||
|
|| !Enum.IsDefined(request.Visibility)
|
||||||
|
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||||
|
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||||
|
|| request.DedicatedFallback is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateUpdate(UpdateSessionRequest request)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode lease = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||||
|
if (lease != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|
||||||
|
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||||
|
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||||
|
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||||
|
|| request.DedicatedFallback is not null
|
||||||
|
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
||||||
|
? RendezvousErrorCode.InvalidRequest
|
||||||
|
: RendezvousErrorCode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode ValidateLeaseRequest(int contractVersion, string leaseToken)
|
||||||
|
{
|
||||||
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||||
|
if (version != RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||||
|
? RendezvousErrorCode.None
|
||||||
|
: RendezvousErrorCode.InvalidRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousErrorCode MapAuthorization(PublisherAuthorizationError error) => error switch
|
||||||
|
{
|
||||||
|
PublisherAuthorizationError.PrincipalExpired => RendezvousErrorCode.AuthenticationRequired,
|
||||||
|
PublisherAuthorizationError.ProtocolNotAllowed => RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
PublisherAuthorizationError.RegionNotAllowed
|
||||||
|
or PublisherAuthorizationError.VisibilityNotAllowed
|
||||||
|
or PublisherAuthorizationError.AnonymousMustBeUnlisted
|
||||||
|
or PublisherAuthorizationError.MetadataNotAllowed => RendezvousErrorCode.InvalidRequest,
|
||||||
|
_ => RendezvousErrorCode.Forbidden,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
|
||||||
|
{
|
||||||
|
RegisterSessionRequest canonical = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
RegionId = request.RegionId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
BuildVersion = request.BuildVersion,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Visibility = request.Visibility,
|
||||||
|
Capacity = new SessionCapacity
|
||||||
|
{
|
||||||
|
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||||
|
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||||
|
},
|
||||||
|
Metadata = request.Metadata
|
||||||
|
.OrderBy(static item => item.Key, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
|
||||||
|
DedicatedFallback = request.DedicatedFallback is null
|
||||||
|
? null
|
||||||
|
: new NetworkEndpoint
|
||||||
|
{
|
||||||
|
AddressFamily = request.DedicatedFallback.AddressFamily,
|
||||||
|
Address = request.DedicatedFallback.Address,
|
||||||
|
Port = request.DedicatedFallback.Port,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
|
||||||
|
byte[] digest = SHA256.HashData(encoded);
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(digest).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
internal interface IWallClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IMonotonicClock
|
||||||
|
{
|
||||||
|
TimeSpan Elapsed { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemRendezvousClock : IWallClock, IMonotonicClock
|
||||||
|
{
|
||||||
|
private readonly long _origin = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public TimeSpan Elapsed => Stopwatch.GetElapsedTime(_origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record EphemeralStoreOptions
|
||||||
|
{
|
||||||
|
public int MaxListings { get; init; } = 25_000;
|
||||||
|
public int MaxPresenceBindings { get; init; } = 25_000;
|
||||||
|
public int MaxJoinAttempts { get; init; } = 10_000;
|
||||||
|
public int MaxOutcomeReports { get; init; } = 35_000;
|
||||||
|
public int MaxReplayEntries { get; init; } = 30_000;
|
||||||
|
public int MaxRevocations { get; init; } = 10_000;
|
||||||
|
public int MaxIdempotencyEntries { get; init; } = 35_000;
|
||||||
|
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
|
||||||
|
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||||
|
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
public TimeSpan ConnectionTicketLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
||||||
|
public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
|
||||||
|
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
RequirePositive(MaxListings, nameof(MaxListings));
|
||||||
|
RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings));
|
||||||
|
RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts));
|
||||||
|
RequirePositive(MaxOutcomeReports, nameof(MaxOutcomeReports));
|
||||||
|
RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries));
|
||||||
|
RequirePositive(MaxRevocations, nameof(MaxRevocations));
|
||||||
|
RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries));
|
||||||
|
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
|
||||||
|
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
|
||||||
|
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
|
||||||
|
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
|
||||||
|
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
||||||
|
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
||||||
|
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
||||||
|
if (ConnectionTicketLifetime > JoinAttemptLifetime)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(ConnectionTicketLifetime),
|
||||||
|
"Connection tickets cannot outlive their join attempt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(IdempotencyLifetime),
|
||||||
|
"Idempotency retention must cover every idempotent resource lifetime.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequirePositive(int value, string name)
|
||||||
|
{
|
||||||
|
if (value <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(name, "Store capacity must be positive.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireDuration(TimeSpan value, TimeSpan maximum, string name)
|
||||||
|
{
|
||||||
|
if (value <= TimeSpan.Zero || value > maximum)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(name, $"Duration must be positive and no greater than {maximum}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct TenantScope(GameId GameId, EnvironmentId EnvironmentId);
|
||||||
|
|
||||||
|
internal readonly struct SecretFingerprint : IEquatable<SecretFingerprint>
|
||||||
|
{
|
||||||
|
private readonly string? _value;
|
||||||
|
|
||||||
|
public SecretFingerprint(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Secret fingerprints must contain 1-128 characters.", nameof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
_value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsValid => !string.IsNullOrWhiteSpace(_value) && _value.Length <= 128;
|
||||||
|
public bool Equals(SecretFingerprint other)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<char> left = _value.AsSpan();
|
||||||
|
ReadOnlySpan<char> right = other._value.AsSpan();
|
||||||
|
if (left.Length != right.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int difference = 0;
|
||||||
|
for (int index = 0; index < left.Length; index++)
|
||||||
|
{
|
||||||
|
difference |= left[index] ^ right[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return difference == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Equals(object? obj) => obj is SecretFingerprint other && Equals(other);
|
||||||
|
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(_value ?? string.Empty);
|
||||||
|
public override string ToString() => "[REDACTED]";
|
||||||
|
public static bool operator ==(SecretFingerprint left, SecretFingerprint right) => left.Equals(right);
|
||||||
|
public static bool operator !=(SecretFingerprint left, SecretFingerprint right) => !left.Equals(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct ObservedEndpoint
|
||||||
|
{
|
||||||
|
public ObservedEndpoint(AddressFamilyKind addressFamily, string address, int port)
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(address, out IPAddress? parsed)
|
||||||
|
|| (addressFamily == AddressFamilyKind.Ipv4 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
|
|| (addressFamily == AddressFamilyKind.Ipv6 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The address must match the declared address family.", nameof(address));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(port));
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressFamily = addressFamily;
|
||||||
|
Address = parsed.ToString();
|
||||||
|
Port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AddressFamilyKind AddressFamily { get; }
|
||||||
|
public string Address { get; }
|
||||||
|
public int Port { get; }
|
||||||
|
public bool IsValid => !string.IsNullOrEmpty(Address)
|
||||||
|
&& Port is >= 1 and <= 65_535
|
||||||
|
&& AddressFamily is AddressFamilyKind.Ipv4 or AddressFamilyKind.Ipv6;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ListingDefinition
|
||||||
|
{
|
||||||
|
public required SessionListingId ListingId { get; init; }
|
||||||
|
public required LeaseId LeaseId { get; init; }
|
||||||
|
public required TenantScope Scope { get; init; }
|
||||||
|
public required string OwnerSubject { get; init; }
|
||||||
|
public required RegionId RegionId { get; init; }
|
||||||
|
public required uint ProtocolVersion { get; init; }
|
||||||
|
public required string BuildVersion { get; init; }
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
public required ListingVisibility Visibility { get; init; }
|
||||||
|
public required PublisherTrustMode TrustMode { get; init; }
|
||||||
|
public required int CurrentPlayers { get; init; }
|
||||||
|
public required int MaximumPlayers { get; init; }
|
||||||
|
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; init; }
|
||||||
|
public required SecretFingerprint LeaseFingerprint { get; init; }
|
||||||
|
public required MediationHandle HostPresenceHandle { get; init; }
|
||||||
|
public required SecretFingerprint HostPresenceFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record StoredListing
|
||||||
|
{
|
||||||
|
public required ListingDefinition Definition { get; init; }
|
||||||
|
public required DateTimeOffset LeaseExpiresAt { get; init; }
|
||||||
|
public required long Version { get; init; }
|
||||||
|
public required bool HasFreshPresence { get; init; }
|
||||||
|
|
||||||
|
public static ListingDefinition Freeze(ListingDefinition source) => source with
|
||||||
|
{
|
||||||
|
Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal),
|
||||||
|
DedicatedFallback = CopyEndpoint(source.DedicatedFallback),
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static NetworkEndpoint? CopyEndpoint(NetworkEndpoint? endpoint) => endpoint is null
|
||||||
|
? null
|
||||||
|
: new NetworkEndpoint
|
||||||
|
{
|
||||||
|
AddressFamily = endpoint.AddressFamily,
|
||||||
|
Address = endpoint.Address,
|
||||||
|
Port = endpoint.Port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record CreateListingCommand(
|
||||||
|
string IdempotencyKey,
|
||||||
|
string RequestFingerprint,
|
||||||
|
ListingDefinition Listing,
|
||||||
|
int OwnerListingLimit = int.MaxValue);
|
||||||
|
|
||||||
|
internal sealed record RenewLeaseCommand(
|
||||||
|
SessionListingId ListingId,
|
||||||
|
LeaseId LeaseId,
|
||||||
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
string OwnerSubject,
|
||||||
|
long ExpectedVersion);
|
||||||
|
|
||||||
|
internal sealed record UpdateListingCommand(
|
||||||
|
SessionListingId ListingId,
|
||||||
|
LeaseId LeaseId,
|
||||||
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
string OwnerSubject,
|
||||||
|
string BuildVersion,
|
||||||
|
string DisplayName,
|
||||||
|
int CurrentPlayers,
|
||||||
|
int MaximumPlayers,
|
||||||
|
IReadOnlyDictionary<string, string> Metadata,
|
||||||
|
NetworkEndpoint? DedicatedFallback);
|
||||||
|
|
||||||
|
internal sealed record DeleteListingCommand(
|
||||||
|
SessionListingId ListingId,
|
||||||
|
LeaseId LeaseId,
|
||||||
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
string OwnerSubject);
|
||||||
|
|
||||||
|
internal sealed record BindHostPresenceCommand(
|
||||||
|
MediationHandle Handle,
|
||||||
|
SecretFingerprint CapabilityFingerprint,
|
||||||
|
ObservedEndpoint PublicEndpoint,
|
||||||
|
ObservedEndpoint? LocalEndpoint);
|
||||||
|
|
||||||
|
internal sealed record VisibleListingQuery(
|
||||||
|
TenantScope Scope,
|
||||||
|
uint ProtocolVersion,
|
||||||
|
RegionId? RegionId,
|
||||||
|
int MaximumResults = ContractLimits.BrowserPageMaxItems,
|
||||||
|
SessionListingId? AfterListingId = null,
|
||||||
|
bool ExcludeFull = false);
|
||||||
|
|
||||||
|
internal enum AttemptPeerRole
|
||||||
|
{
|
||||||
|
Host = 1,
|
||||||
|
Client = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record CreateJoinAttemptCommand
|
||||||
|
{
|
||||||
|
public required string IdempotencyOwner { get; init; }
|
||||||
|
public required string IdempotencyKey { get; init; }
|
||||||
|
public required string RequestFingerprint { get; init; }
|
||||||
|
public required string ClientSubject { get; init; }
|
||||||
|
public required JoinAttemptId AttemptId { get; init; }
|
||||||
|
public required MediationHandle MediationHandle { get; init; }
|
||||||
|
public required TenantScope Scope { get; init; }
|
||||||
|
public required SessionListingId ListingId { get; init; }
|
||||||
|
public required uint ProtocolVersion { get; init; }
|
||||||
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; init; }
|
||||||
|
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
||||||
|
|
||||||
|
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record AttemptEndpointBinding(
|
||||||
|
ObservedEndpoint PublicEndpoint,
|
||||||
|
ObservedEndpoint? LocalEndpoint);
|
||||||
|
|
||||||
|
internal sealed record StoredJoinAttempt
|
||||||
|
{
|
||||||
|
public required JoinAttemptId AttemptId { get; init; }
|
||||||
|
public required MediationHandle MediationHandle { get; init; }
|
||||||
|
public required TenantScope Scope { get; init; }
|
||||||
|
public required SessionListingId ListingId { get; init; }
|
||||||
|
public required string ClientSubject { get; init; }
|
||||||
|
public required uint ProtocolVersion { get; init; }
|
||||||
|
public required string IdempotencyKey { get; init; }
|
||||||
|
public required string RequestFingerprint { get; init; }
|
||||||
|
public required string CapabilityDerivationSalt { get; init; }
|
||||||
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
||||||
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||||
|
public NetworkEndpoint? DedicatedFallback { get; init; }
|
||||||
|
public required DateTimeOffset ExpiresAt { get; init; }
|
||||||
|
public required TimeSpan CreatedAtMonotonic { get; init; }
|
||||||
|
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
||||||
|
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
||||||
|
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||||
|
public required bool IntroductionConsumed { get; init; }
|
||||||
|
public required bool ConnectionTicketConsumed { get; init; }
|
||||||
|
public required bool IsCancelled { get; init; }
|
||||||
|
|
||||||
|
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record HostJoinAttemptQuery(
|
||||||
|
SessionListingId ListingId,
|
||||||
|
SecretFingerprint LeaseFingerprint,
|
||||||
|
int MaximumResults,
|
||||||
|
JoinAttemptId? AfterAttemptId = null);
|
||||||
|
|
||||||
|
internal sealed record BindAttemptEndpointCommand(
|
||||||
|
MediationHandle Handle,
|
||||||
|
AttemptPeerRole Role,
|
||||||
|
SecretFingerprint CapabilityFingerprint,
|
||||||
|
ObservedEndpoint PublicEndpoint,
|
||||||
|
ObservedEndpoint? LocalEndpoint);
|
||||||
|
|
||||||
|
internal sealed record IntroductionEndpoints(
|
||||||
|
StoredJoinAttempt Attempt,
|
||||||
|
AttemptEndpointBinding Host,
|
||||||
|
AttemptEndpointBinding Client)
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId => Attempt.AttemptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record CancelJoinAttemptCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ClientCapabilityFingerprint);
|
||||||
|
|
||||||
|
internal sealed record ReportConnectionOutcomeCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ClientCapabilityFingerprint,
|
||||||
|
ConnectionOutcomeKind Outcome,
|
||||||
|
ConnectionElapsedBucket ElapsedBucket);
|
||||||
|
|
||||||
|
internal sealed record StoredConnectionOutcome(
|
||||||
|
ConnectionOutcomeKind Outcome,
|
||||||
|
ConnectionElapsedBucket ElapsedBucket);
|
||||||
|
|
||||||
|
internal sealed record ConsumeConnectionTicketCommand(
|
||||||
|
JoinAttemptId AttemptId,
|
||||||
|
SecretFingerprint ConnectionTicketFingerprint);
|
||||||
|
|
||||||
|
internal sealed record ReplayConsumption(
|
||||||
|
string Namespace,
|
||||||
|
string Key,
|
||||||
|
TimeSpan? Lifetime = null);
|
||||||
|
|
||||||
|
internal enum StoreResultCode
|
||||||
|
{
|
||||||
|
Success = 0,
|
||||||
|
NotFound = 1,
|
||||||
|
Expired = 2,
|
||||||
|
Revoked = 3,
|
||||||
|
Conflict = 4,
|
||||||
|
CapacityExceeded = 5,
|
||||||
|
Draining = 6,
|
||||||
|
ReplayRejected = 7,
|
||||||
|
ServiceUnavailable = 8,
|
||||||
|
StaleHost = 9,
|
||||||
|
IncompatibleProtocol = 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record StoreResult<T>(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false)
|
||||||
|
{
|
||||||
|
public bool Succeeded => Code == StoreResultCode.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IEphemeralRendezvousStore
|
||||||
|
{
|
||||||
|
Guid InstanceId { get; }
|
||||||
|
bool IsAvailable { get; }
|
||||||
|
bool IsDraining { get; }
|
||||||
|
|
||||||
|
StoreResult<StoredListing> CreateListing(CreateListingCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredListing> RenewLease(RenewLeaseCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredListing> UpdateListing(UpdateListingCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> DeleteListing(DeleteListingCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredListing> GetListing(SessionListingId listingId, bool requireFreshPresence, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredConnectionOutcome> ReportConnectionOutcome(ReportConnectionOutcomeCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
|
||||||
|
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
|
||||||
|
void BeginDrain(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
internal static class StoreResultMapping
|
||||||
|
{
|
||||||
|
public static RendezvousErrorCode ToContractError(this StoreResultCode code) => code switch
|
||||||
|
{
|
||||||
|
StoreResultCode.Success => RendezvousErrorCode.None,
|
||||||
|
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
|
||||||
|
StoreResultCode.Expired => RendezvousErrorCode.Expired,
|
||||||
|
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
|
||||||
|
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
|
||||||
|
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
|
||||||
|
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
|
||||||
|
StoreResultCode.StaleHost => RendezvousErrorCode.StaleHost,
|
||||||
|
StoreResultCode.IncompatibleProtocol => RendezvousErrorCode.IncompatibleProtocol,
|
||||||
|
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
_ => RendezvousErrorCode.InternalError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
|
internal static class LiteNetNatRequestCodec
|
||||||
|
{
|
||||||
|
private const byte NatMessageProperty = 17;
|
||||||
|
private const int TypeIdentifierLength = 8;
|
||||||
|
private const int TokenLengthPrefix = NatPunchRequestTokenCodec.EncodedLength + 1;
|
||||||
|
// LiteNetLib 2.1.4's private NatIntroduceRequest type ID. The native socket
|
||||||
|
// integration test deliberately fails if a package upgrade changes this wire value.
|
||||||
|
private static ReadOnlySpan<byte> RequestTypeIdentifier =>
|
||||||
|
[0x88, 0xbe, 0x10, 0x26, 0xbf, 0xb1, 0x66, 0x9c];
|
||||||
|
|
||||||
|
public static bool TryDecode(
|
||||||
|
ReadOnlySpan<byte> datagram,
|
||||||
|
out IPEndPoint? claimedLocalEndpoint,
|
||||||
|
out string? token)
|
||||||
|
{
|
||||||
|
claimedLocalEndpoint = null;
|
||||||
|
token = null;
|
||||||
|
if (datagram.Length < 1 + TypeIdentifierLength + 1 + 4 + 2 + 2
|
||||||
|
+ NatPunchRequestTokenCodec.EncodedLength
|
||||||
|
|| datagram[0] != NatMessageProperty
|
||||||
|
|| !datagram.Slice(1, TypeIdentifierLength).SequenceEqual(RequestTypeIdentifier))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int offset = 1 + TypeIdentifierLength;
|
||||||
|
int addressLength = datagram[offset++] switch
|
||||||
|
{
|
||||||
|
0 => 4,
|
||||||
|
1 => 16,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
int expectedLength = offset + addressLength + 2 + 2
|
||||||
|
+ NatPunchRequestTokenCodec.EncodedLength;
|
||||||
|
if (addressLength == 0 || datagram.Length != expectedLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress localAddress = new(datagram.Slice(offset, addressLength));
|
||||||
|
offset += addressLength;
|
||||||
|
int localPort = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
|
||||||
|
offset += 2;
|
||||||
|
int encodedTokenLength = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
|
||||||
|
offset += 2;
|
||||||
|
if (localPort == 0 || encodedTokenLength != TokenLengthPrefix)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> tokenBytes = datagram.Slice(
|
||||||
|
offset,
|
||||||
|
NatPunchRequestTokenCodec.EncodedLength);
|
||||||
|
for (int index = 0; index < tokenBytes.Length; index++)
|
||||||
|
{
|
||||||
|
if (tokenBytes[index] > 0x7f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedLocalEndpoint = new(localAddress, localPort);
|
||||||
|
token = Encoding.ASCII.GetString(tokenBytes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
|
internal interface INatIntroductionSink
|
||||||
|
{
|
||||||
|
void Introduce(NatIntroductionPlan plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record NatIntroductionPlan(
|
||||||
|
IPEndPoint HostLocal,
|
||||||
|
IPEndPoint HostPublic,
|
||||||
|
IPEndPoint ClientLocal,
|
||||||
|
IPEndPoint ClientPublic,
|
||||||
|
string IntroductionToken)
|
||||||
|
{
|
||||||
|
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum NatMediationResult
|
||||||
|
{
|
||||||
|
Dropped = 0,
|
||||||
|
HostPresenceAccepted = 1,
|
||||||
|
HostPresenceRejected = 2,
|
||||||
|
WaitingForPeer = 3,
|
||||||
|
Introduced = 4,
|
||||||
|
Duplicate = 5,
|
||||||
|
Rejected = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class NatMediationProcessor(
|
||||||
|
IEphemeralRendezvousStore store,
|
||||||
|
ISessionCapabilityService capabilities,
|
||||||
|
JoinAttemptService joinAttempts,
|
||||||
|
AbuseProtectionService? abuseProtection = null,
|
||||||
|
RendezvousTelemetry? telemetry = null,
|
||||||
|
IMonotonicClock? monotonicClock = null)
|
||||||
|
{
|
||||||
|
public NatMediationResult ProcessDatagram(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!TryAcceptIngress(observedPublicEndpoint, "frozen"))
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProcessDatagramAfterIngress(
|
||||||
|
encoded,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryAcceptIngress(IPEndPoint observedPublicEndpoint, string operation) =>
|
||||||
|
abuseProtection is null
|
||||||
|
|| abuseProtection.TryAcceptUdpIngress(observedPublicEndpoint.Address, operation);
|
||||||
|
|
||||||
|
internal NatMediationResult ProcessDatagramAfterIngress(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
using Activity? activity = telemetry?.StartActivity("UDP frozen", ActivityKind.Server);
|
||||||
|
NatMediationResult result = ProcessDatagramCore(
|
||||||
|
encoded,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
telemetry?.RecordUdp(
|
||||||
|
"frozen",
|
||||||
|
result.ToString(),
|
||||||
|
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private NatMediationResult ProcessDatagramCore(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
|
||||||
|
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||||
|
|| datagram is null
|
||||||
|
|| datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters
|
||||||
|
|| !IPAddress.TryParse(datagram.LocalAddress, out IPAddress? localAddress))
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPEndPoint claimedLocalEndpoint = new(localAddress, datagram.LocalPort);
|
||||||
|
NatPunchPeerRole role = datagram.MessageType == UdpPresenceMessageType.ClientPresence
|
||||||
|
? NatPunchPeerRole.Client
|
||||||
|
: NatPunchPeerRole.HostPresence;
|
||||||
|
bool observedIpv6 = observedPublicEndpoint.AddressFamily == AddressFamily.InterNetworkV6
|
||||||
|
&& !observedPublicEndpoint.Address.IsIPv4MappedToIPv6;
|
||||||
|
if (role == NatPunchPeerRole.Client && observedIpv6)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
NatMediationResult result = ProcessRequestCore(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
if (role != NatPunchPeerRole.HostPresence
|
||||||
|
|| result != NatMediationResult.HostPresenceRejected
|
||||||
|
|| observedIpv6)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProcessRequestCore(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
datagram.MediationHandle,
|
||||||
|
datagram.Capability),
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NatMediationResult ProcessRequest(
|
||||||
|
IPEndPoint claimedLocalEndpoint,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
string token,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(claimedLocalEndpoint);
|
||||||
|
ArgumentNullException.ThrowIfNull(observedPublicEndpoint);
|
||||||
|
ArgumentNullException.ThrowIfNull(introductionSink);
|
||||||
|
|
||||||
|
if (!TryAcceptIngress(observedPublicEndpoint, "litenet-or-invalid"))
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProcessRequestAfterIngress(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
token,
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal NatMediationResult ProcessRequestAfterIngress(
|
||||||
|
IPEndPoint claimedLocalEndpoint,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
string token,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
using Activity? activity = telemetry?.StartActivity("UDP litenet", ActivityKind.Server);
|
||||||
|
NatMediationResult result = ProcessRequestCore(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
token,
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
telemetry?.RecordUdp(
|
||||||
|
"litenet",
|
||||||
|
result.ToString(),
|
||||||
|
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private NatMediationResult ProcessRequestCore(
|
||||||
|
IPEndPoint claimedLocalEndpoint,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
string token,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
|
||||||
|
if (!NatPunchRequestTokenCodec.TryDecode(token, out NatPunchRequestToken? request)
|
||||||
|
|| request is null
|
||||||
|
|| !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint)
|
||||||
|
|| !capabilities.TryFingerprint(request.Capability, out SecretFingerprint fingerprint))
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
string operation = request.Role.ToString();
|
||||||
|
if (abuseProtection is not null
|
||||||
|
&& !abuseProtection.TryAcceptUdpIdentity(
|
||||||
|
operation,
|
||||||
|
observedPublicEndpoint.Address,
|
||||||
|
request.Capability,
|
||||||
|
request.MediationHandle.ToString()))
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
ObservedEndpoint? localEndpoint = TryCreatePrivateCandidate(
|
||||||
|
claimedLocalEndpoint,
|
||||||
|
publicEndpoint.AddressFamily,
|
||||||
|
out ObservedEndpoint candidate)
|
||||||
|
? candidate
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (request.Role == NatPunchPeerRole.HostPresence)
|
||||||
|
{
|
||||||
|
StoreResult<StoredListing> presence = store.BindHostPresence(new(
|
||||||
|
request.MediationHandle,
|
||||||
|
fingerprint,
|
||||||
|
publicEndpoint,
|
||||||
|
localEndpoint), cancellationToken);
|
||||||
|
return presence.Succeeded
|
||||||
|
? NatMediationResult.HostPresenceAccepted
|
||||||
|
: NatMediationResult.HostPresenceRejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
AttemptPeerRole role = request.Role switch
|
||||||
|
{
|
||||||
|
NatPunchPeerRole.Host => AttemptPeerRole.Host,
|
||||||
|
NatPunchPeerRole.Client => AttemptPeerRole.Client,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
if (role == default)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<StoredJoinAttempt> bound = store.BindAttemptEndpoint(new(
|
||||||
|
request.MediationHandle,
|
||||||
|
role,
|
||||||
|
fingerprint,
|
||||||
|
publicEndpoint,
|
||||||
|
localEndpoint), cancellationToken);
|
||||||
|
if (!bound.Succeeded || bound.Value is null)
|
||||||
|
{
|
||||||
|
return bound.Code == StoreResultCode.ReplayRejected
|
||||||
|
? NatMediationResult.Rejected
|
||||||
|
: NatMediationResult.Dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredJoinAttempt attempt = bound.Value;
|
||||||
|
if (attempt.IntroductionConsumed)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Duplicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.HostEndpoint is null || attempt.ClientEndpoint is null)
|
||||||
|
{
|
||||||
|
return NatMediationResult.WaitingForPeer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.HostEndpoint.PublicEndpoint.AddressFamily
|
||||||
|
!= attempt.ClientEndpoint.PublicEndpoint.AddressFamily)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<IntroductionEndpoints> consumed = store.ConsumeIntroduction(
|
||||||
|
request.MediationHandle,
|
||||||
|
cancellationToken);
|
||||||
|
if (!consumed.Succeeded || consumed.Value is null)
|
||||||
|
{
|
||||||
|
return consumed.Code == StoreResultCode.ReplayRejected
|
||||||
|
? NatMediationResult.Duplicate
|
||||||
|
: NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinAttemptServiceResult<ConnectionTicketGrant> ticket = joinAttempts.IssueConnectionTicket(
|
||||||
|
consumed.Value.Attempt);
|
||||||
|
if (!ticket.Succeeded || ticket.Value is null)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value));
|
||||||
|
if (telemetry is not null && monotonicClock is not null)
|
||||||
|
{
|
||||||
|
telemetry.RecordPairingLatency(Math.Max(
|
||||||
|
0,
|
||||||
|
(monotonicClock.Elapsed - consumed.Value.Attempt.CreatedAtMonotonic)
|
||||||
|
.TotalMilliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NatMediationResult.Introduced;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SocketException
|
||||||
|
or InvalidOperationException
|
||||||
|
or ArgumentException)
|
||||||
|
{
|
||||||
|
return NatMediationResult.Rejected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NatIntroductionPlan CreatePlan(
|
||||||
|
IntroductionEndpoints endpoints,
|
||||||
|
ConnectionTicketGrant ticket)
|
||||||
|
{
|
||||||
|
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
|
||||||
|
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
|
||||||
|
bool sameNat = hostPublic.Address.Equals(clientPublic.Address);
|
||||||
|
IPEndPoint hostLocal = sameNat && endpoints.Host.LocalEndpoint is { } hostCandidate
|
||||||
|
? ToIpEndpoint(hostCandidate)
|
||||||
|
: hostPublic;
|
||||||
|
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
|
||||||
|
? ToIpEndpoint(clientCandidate)
|
||||||
|
: clientPublic;
|
||||||
|
return new(
|
||||||
|
hostLocal,
|
||||||
|
hostPublic,
|
||||||
|
clientLocal,
|
||||||
|
clientPublic,
|
||||||
|
ticket.Ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreateObservedEndpoint(
|
||||||
|
IPEndPoint source,
|
||||||
|
out ObservedEndpoint endpoint)
|
||||||
|
{
|
||||||
|
endpoint = default;
|
||||||
|
if (source.Port is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress address = source.Address.IsIPv4MappedToIPv6
|
||||||
|
? source.Address.MapToIPv4()
|
||||||
|
: source.Address;
|
||||||
|
if (address.Equals(IPAddress.Any)
|
||||||
|
|| address.Equals(IPAddress.IPv6Any)
|
||||||
|
|| address.IsIPv6Multicast
|
||||||
|
|| IsIpv4MulticastOrBroadcast(address)
|
||||||
|
|| (address.AddressFamily == AddressFamily.InterNetworkV6
|
||||||
|
&& !IsGlobalIpv6(address)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressFamilyKind family = address.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||||
|
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
if (family == default)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = new(family, address.ToString(), source.Port);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreatePrivateCandidate(
|
||||||
|
IPEndPoint source,
|
||||||
|
AddressFamilyKind publicFamily,
|
||||||
|
out ObservedEndpoint endpoint)
|
||||||
|
{
|
||||||
|
endpoint = default;
|
||||||
|
if (source.Port is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress address = source.Address.IsIPv4MappedToIPv6
|
||||||
|
? source.Address.MapToIPv4()
|
||||||
|
: source.Address;
|
||||||
|
AddressFamilyKind family = address.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||||
|
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
if (family != publicFamily || !IsPrivateUnicast(address))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = new(family, address.ToString(), source.Port);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPrivateUnicast(IPAddress address)
|
||||||
|
{
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return address.AddressFamily switch
|
||||||
|
{
|
||||||
|
AddressFamily.InterNetwork => bytes[0] == 10
|
||||||
|
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||||
|
|| (bytes[0] == 192 && bytes[1] == 168),
|
||||||
|
AddressFamily.InterNetworkV6 => (bytes[0] & 0xfe) == 0xfc,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGlobalIpv6(IPAddress address) =>
|
||||||
|
!address.Equals(IPAddress.IPv6Loopback)
|
||||||
|
&& !address.Equals(IPAddress.IPv6Any)
|
||||||
|
&& !address.IsIPv6LinkLocal
|
||||||
|
&& !address.IsIPv6Multicast
|
||||||
|
&& !address.IsIPv6SiteLocal
|
||||||
|
&& !IsPrivateUnicast(address)
|
||||||
|
&& !IsDocumentationIpv6(address);
|
||||||
|
|
||||||
|
private static bool IsIpv4MulticastOrBroadcast(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address.AddressFamily != AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return bytes[0] >= 224 || bytes.All(static value => value == byte.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDocumentationIpv6(IPAddress address)
|
||||||
|
{
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPEndPoint ToIpEndpoint(ObservedEndpoint endpoint) =>
|
||||||
|
new(IPAddress.Parse(endpoint.Address), endpoint.Port);
|
||||||
|
}
|
||||||
@@ -18,9 +18,17 @@ public sealed class UdpMediatorOptions
|
|||||||
[Required]
|
[Required]
|
||||||
public string ListenAddress { get; set; } = "0.0.0.0";
|
public string ListenAddress { get; set; } = "0.0.0.0";
|
||||||
|
|
||||||
|
public string? Ipv6ListenAddress { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
|
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Range(0, 65_535)]
|
[Range(0, 65_535)]
|
||||||
public int Port { get; set; } = 9050;
|
public int Port { get; set; } = 9050;
|
||||||
|
|
||||||
|
[Range(1, 4_096)]
|
||||||
|
public int MaxDatagramsPerPoll { get; set; } = 256;
|
||||||
|
|
||||||
|
[Range(1, 100)]
|
||||||
|
public int PollIntervalMilliseconds { get; set; } = 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +1,138 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
using LiteNetLib.Layers;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
|
||||||
/// <summary>
|
internal sealed partial class UdpMediatorService : BackgroundService
|
||||||
/// Owns the cancellable UDP socket used by the future NAT mediator.
|
|
||||||
/// </summary>
|
|
||||||
public sealed partial class UdpMediatorService : BackgroundService
|
|
||||||
{
|
{
|
||||||
private readonly ILogger<UdpMediatorService> _logger;
|
private readonly ILogger<UdpMediatorService> _logger;
|
||||||
private readonly UdpMediatorOptions _options;
|
private readonly UdpMediatorOptions _options;
|
||||||
private UdpClient? _udpClient;
|
private readonly NatMediationProcessor _processor;
|
||||||
|
private LiteNetManager? _manager;
|
||||||
|
private LiteNetIntroductionSink? _introductionSink;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new UDP mediator service.
|
|
||||||
/// </summary>
|
|
||||||
public UdpMediatorService(
|
public UdpMediatorService(
|
||||||
IOptions<UdpMediatorOptions> options,
|
IOptions<UdpMediatorOptions> options,
|
||||||
ILogger<UdpMediatorService> logger)
|
ILogger<UdpMediatorService> logger,
|
||||||
|
NatMediationProcessor processor)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_processor = processor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the bound endpoint after startup completes.
|
|
||||||
/// </summary>
|
|
||||||
public IPEndPoint? LocalEndpoint { get; private set; }
|
public IPEndPoint? LocalEndpoint { get; private set; }
|
||||||
|
public IPEndPoint? LocalIpv6Endpoint { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override Task StartAsync(CancellationToken cancellationToken)
|
public override Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (_manager is not null)
|
||||||
if (_udpClient is not null)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The UDP mediator is already running.");
|
throw new InvalidOperationException("The UDP mediator is already running.");
|
||||||
}
|
}
|
||||||
|
|
||||||
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
|
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
|
||||||
UdpClient udpClient = new(new IPEndPoint(listenAddress, _options.Port));
|
if (listenAddress.AddressFamily != AddressFamily.InterNetwork)
|
||||||
_udpClient = udpClient;
|
{
|
||||||
IPEndPoint localEndpoint =
|
throw new InvalidOperationException("The required UDP listen address must be IPv4.");
|
||||||
(IPEndPoint?)udpClient.Client.LocalEndPoint
|
}
|
||||||
?? throw new InvalidOperationException("The UDP socket did not expose its bound endpoint.");
|
|
||||||
LocalEndpoint = localEndpoint;
|
|
||||||
|
|
||||||
LogMediatorListening(_logger, localEndpoint.Address, localEndpoint.Port);
|
IPAddress? ipv6ListenAddress = string.IsNullOrWhiteSpace(_options.Ipv6ListenAddress)
|
||||||
|
? null
|
||||||
|
: IPAddress.Parse(_options.Ipv6ListenAddress);
|
||||||
|
if (ipv6ListenAddress is not null
|
||||||
|
&& ipv6ListenAddress.AddressFamily != AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The optional UDP IPv6 listen address must be IPv6.");
|
||||||
|
}
|
||||||
|
|
||||||
|
EventBasedLiteNetListener listener = new();
|
||||||
|
RendezvousPacketLayer packetLayer = new(_processor);
|
||||||
|
LiteNetManager manager = new(listener, packetLayer)
|
||||||
|
{
|
||||||
|
NatPunchEnabled = true,
|
||||||
|
IPv6Enabled = ipv6ListenAddress is not null,
|
||||||
|
UnsyncedEvents = true,
|
||||||
|
MaxPacketPerManualReceive = _options.MaxDatagramsPerPoll,
|
||||||
|
};
|
||||||
|
manager.NatPunchModule.UnsyncedEvents = true;
|
||||||
|
_introductionSink = new(manager.NatPunchModule);
|
||||||
|
packetLayer.Attach(_introductionSink);
|
||||||
|
|
||||||
|
if (!manager.StartInManualMode(
|
||||||
|
listenAddress,
|
||||||
|
ipv6ListenAddress ?? IPAddress.IPv6Any,
|
||||||
|
_options.Port))
|
||||||
|
{
|
||||||
|
_introductionSink = null;
|
||||||
|
manager.Stop();
|
||||||
|
throw new InvalidOperationException("The UDP mediator could not bind its LiteNetLib socket.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager = manager;
|
||||||
|
LocalEndpoint = new(listenAddress, manager.LocalPort);
|
||||||
|
LocalIpv6Endpoint = ipv6ListenAddress is null
|
||||||
|
? null
|
||||||
|
: new(ipv6ListenAddress, manager.LocalPort);
|
||||||
|
LogMediatorListening(_logger, listenAddress, manager.LocalPort);
|
||||||
return base.StartAsync(cancellationToken);
|
return base.StartAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_udpClient?.Dispose();
|
StopManager();
|
||||||
_udpClient = null;
|
|
||||||
LocalEndpoint = null;
|
|
||||||
LogMediatorStopped(_logger);
|
LogMediatorStopped(_logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
_udpClient?.Dispose();
|
StopManager();
|
||||||
_udpClient = null;
|
|
||||||
LocalEndpoint = null;
|
|
||||||
base.Dispose();
|
base.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
UdpClient udpClient = _udpClient
|
LiteNetManager manager = _manager
|
||||||
?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
|
?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
|
||||||
|
long previous = Stopwatch.GetTimestamp();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_ = await udpClient.ReceiveAsync(stoppingToken).ConfigureAwait(false);
|
manager.PollEvents();
|
||||||
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
|
manager.NatPunchModule.PollEvents();
|
||||||
|
long current = Stopwatch.GetTimestamp();
|
||||||
|
manager.ManualUpdate((float)Stopwatch.GetElapsedTime(previous, current).TotalMilliseconds);
|
||||||
|
previous = current;
|
||||||
|
await Task.Delay(_options.PollIntervalMilliseconds, stoppingToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Expected during normal shutdown.
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// Disposing the socket is the fallback that releases a blocked receive.
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
LocalEndpoint = null;
|
LocalEndpoint = null;
|
||||||
|
LocalIpv6Endpoint = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void StopManager()
|
||||||
|
{
|
||||||
|
LiteNetManager? manager = Interlocked.Exchange(ref _manager, null);
|
||||||
|
_introductionSink = null;
|
||||||
|
LocalEndpoint = null;
|
||||||
|
LocalIpv6Endpoint = null;
|
||||||
|
manager?.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
[LoggerMessage(
|
[LoggerMessage(
|
||||||
EventId = 1,
|
EventId = 1,
|
||||||
Level = LogLevel.Information,
|
Level = LogLevel.Information,
|
||||||
@@ -113,4 +147,72 @@ public sealed partial class UdpMediatorService : BackgroundService
|
|||||||
Level = LogLevel.Information,
|
Level = LogLevel.Information,
|
||||||
Message = "UDP mediator stopped")]
|
Message = "UDP mediator stopped")]
|
||||||
private static partial void LogMediatorStopped(ILogger logger);
|
private static partial void LogMediatorStopped(ILogger logger);
|
||||||
|
|
||||||
|
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
|
||||||
|
{
|
||||||
|
public void Introduce(NatIntroductionPlan plan) => module.NatIntroduce(
|
||||||
|
plan.HostLocal,
|
||||||
|
plan.HostPublic,
|
||||||
|
plan.ClientLocal,
|
||||||
|
plan.ClientPublic,
|
||||||
|
plan.IntroductionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
|
||||||
|
{
|
||||||
|
private INatIntroductionSink? _sink;
|
||||||
|
|
||||||
|
public void Attach(INatIntroductionSink sink) => _sink = sink;
|
||||||
|
|
||||||
|
public override void ProcessInboundPacket(
|
||||||
|
ref IPEndPoint endPoint,
|
||||||
|
ref byte[] data,
|
||||||
|
ref int length)
|
||||||
|
{
|
||||||
|
bool isFrozenEnvelope = length >= 2
|
||||||
|
&& data[0] == RendezvousUdpCodec.MagicFirst
|
||||||
|
&& data[1] == RendezvousUdpCodec.MagicSecond;
|
||||||
|
if (!processor.TryAcceptIngress(
|
||||||
|
endPoint,
|
||||||
|
isFrozenEnvelope ? "frozen" : "litenet-or-invalid"))
|
||||||
|
{
|
||||||
|
Drop(ref length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
INatIntroductionSink? sink = _sink;
|
||||||
|
if (isFrozenEnvelope)
|
||||||
|
{
|
||||||
|
if (sink is not null)
|
||||||
|
{
|
||||||
|
_ = processor.ProcessDatagramAfterIngress(
|
||||||
|
data.AsSpan(0, length), endPoint, sink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (sink is not null
|
||||||
|
&& LiteNetNatRequestCodec.TryDecode(
|
||||||
|
data.AsSpan(0, length),
|
||||||
|
out IPEndPoint? claimedLocalEndpoint,
|
||||||
|
out string? token)
|
||||||
|
&& claimedLocalEndpoint is not null
|
||||||
|
&& token is not null)
|
||||||
|
{
|
||||||
|
_ = processor.ProcessRequestAfterIngress(
|
||||||
|
claimedLocalEndpoint, endPoint, token, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
|
||||||
|
Drop(ref length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void ProcessOutBoundPacket(
|
||||||
|
ref IPEndPoint endPoint,
|
||||||
|
ref byte[] data,
|
||||||
|
ref int offset,
|
||||||
|
ref int length)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Drop(ref int length) => length = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"Rendezvous": {
|
"Rendezvous": {
|
||||||
|
"AbuseProtection": {
|
||||||
|
"OperatorAllowedAddresses": ["127.0.0.1", "::1"]
|
||||||
|
},
|
||||||
"Provisioning": {
|
"Provisioning": {
|
||||||
"Issuer": "final-factory-rendezvous-development",
|
"Issuer": "final-factory-rendezvous-development",
|
||||||
"Audience": "final-factory-rendezvous",
|
"Audience": "final-factory-rendezvous",
|
||||||
@@ -14,6 +17,14 @@
|
|||||||
"NotBefore": "2025-01-01T00:00:00Z",
|
"NotBefore": "2025-01-01T00:00:00Z",
|
||||||
"SignUntil": "2035-01-01T00:00:00Z",
|
"SignUntil": "2035-01-01T00:00:00Z",
|
||||||
"VerifyUntil": "2035-01-02T00:00:00Z"
|
"VerifyUntil": "2035-01-02T00:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"KeyId": "development-operator-1",
|
||||||
|
"SecretReference": "development:ephemeral/rendezvous-operator-signing",
|
||||||
|
"CredentialKinds": ["Operator"],
|
||||||
|
"NotBefore": "2025-01-01T00:00:00Z",
|
||||||
|
"SignUntil": "2035-01-01T00:00:00Z",
|
||||||
|
"VerifyUntil": "2035-01-02T00:00:00Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Games": [
|
"Games": [
|
||||||
|
|||||||
@@ -1,8 +1,60 @@
|
|||||||
{
|
{
|
||||||
"Rendezvous": {
|
"Rendezvous": {
|
||||||
|
"Deployment": {
|
||||||
|
"PublicHttpBaseUrl": "",
|
||||||
|
"PublicUdpHost": "",
|
||||||
|
"PublicUdpPort": 9050,
|
||||||
|
"DrainDeadlineSeconds": 30,
|
||||||
|
"MinimumDrainSeconds": 1,
|
||||||
|
"SingleActiveInstance": true,
|
||||||
|
"AllowPrivatePublicEndpoints": false
|
||||||
|
},
|
||||||
"Udp": {
|
"Udp": {
|
||||||
"ListenAddress": "0.0.0.0",
|
"ListenAddress": "0.0.0.0",
|
||||||
"Port": 9050
|
"Port": 9050,
|
||||||
|
"MaxDatagramsPerPoll": 256,
|
||||||
|
"PollIntervalMilliseconds": 2
|
||||||
|
},
|
||||||
|
"Audit": {
|
||||||
|
"MaxEntries": 10000,
|
||||||
|
"RetentionDays": 30
|
||||||
|
},
|
||||||
|
"AbuseProtection": {
|
||||||
|
"WindowSeconds": 1,
|
||||||
|
"MaxTrackedKeys": 100000,
|
||||||
|
"CriticalTrackedKeyReserve": 2048,
|
||||||
|
"UdpTrackedKeyLimit": 70000,
|
||||||
|
"TrustedProxyAddresses": [],
|
||||||
|
"OperatorAllowedAddresses": [],
|
||||||
|
"HealthGlobalRequestsPerWindow": 1000,
|
||||||
|
"HealthGlobalConcurrency": 32,
|
||||||
|
"HealthIpPrefixRequestsPerWindow": 120,
|
||||||
|
"HealthIpPrefixConcurrency": 8,
|
||||||
|
"OperatorGlobalRequestsPerWindow": 1000,
|
||||||
|
"OperatorGlobalConcurrency": 32,
|
||||||
|
"OperatorIpPrefixRequestsPerWindow": 120,
|
||||||
|
"OperatorIpPrefixConcurrency": 8,
|
||||||
|
"HttpGlobalRequestsPerWindow": 20000,
|
||||||
|
"HttpOptionalRequestsPerWindow": 18000,
|
||||||
|
"HttpIpPrefixRequestsPerWindow": 500,
|
||||||
|
"HttpOptionalIpPrefixRequestsPerWindow": 450,
|
||||||
|
"HttpOperationRequestsPerWindow": 5000,
|
||||||
|
"HttpTenantRequestsPerWindow": 2000,
|
||||||
|
"HttpPrincipalRequestsPerWindow": 500,
|
||||||
|
"HttpResourceRequestsPerWindow": 200,
|
||||||
|
"HttpGlobalConcurrency": 1024,
|
||||||
|
"HttpOptionalConcurrency": 768,
|
||||||
|
"HttpIpPrefixConcurrency": 64,
|
||||||
|
"HttpOptionalIpPrefixConcurrency": 48,
|
||||||
|
"HttpOperationConcurrency": 256,
|
||||||
|
"HttpTenantConcurrency": 256,
|
||||||
|
"HttpPrincipalConcurrency": 32,
|
||||||
|
"HttpResourceConcurrency": 16,
|
||||||
|
"UdpGlobalDatagramsPerWindow": 100000,
|
||||||
|
"UdpIpPrefixDatagramsPerWindow": 2000,
|
||||||
|
"UdpOperationDatagramsPerWindow": 50000,
|
||||||
|
"UdpCapabilityDatagramsPerWindow": 120,
|
||||||
|
"UdpResourceDatagramsPerWindow": 240
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using LiteNetLib;
|
||||||
|
using LiteNetLib.Utils;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
internal sealed class DirectEchoProtocol : IDisposable
|
||||||
|
{
|
||||||
|
private const string PingPrefix = "rv1-ping:";
|
||||||
|
private const string EchoPrefix = "rv1-echo:";
|
||||||
|
private const string AckPrefix = "rv1-ack:";
|
||||||
|
private const string DonePrefix = "rv1-done:";
|
||||||
|
private readonly EventBasedNetListener _events;
|
||||||
|
private readonly bool _host;
|
||||||
|
private readonly Dictionary<NetPeer, string> _hostNonces = [];
|
||||||
|
private readonly TaskCompletionSource<bool> _completed = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private string? _nonce;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal DirectEchoProtocol(EventBasedNetListener events, bool host)
|
||||||
|
{
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_host = host;
|
||||||
|
_events.NetworkReceiveEvent += OnReceive;
|
||||||
|
_events.PeerDisconnectedEvent += OnPeerDisconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Task Completion => _completed.Task;
|
||||||
|
internal int PendingHostExchangeCount => _hostNonces.Count;
|
||||||
|
internal event Action<NetPeer>? ExchangeCompleted;
|
||||||
|
|
||||||
|
internal void BeginJoin(NetPeer peer)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (_host || _nonce is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The direct echo exchange is already active.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||||
|
Send(peer, PingPrefix + _nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_events.NetworkReceiveEvent -= OnReceive;
|
||||||
|
_events.PeerDisconnectedEvent -= OnPeerDisconnected;
|
||||||
|
_hostNonces.Clear();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnReceive(
|
||||||
|
NetPeer peer,
|
||||||
|
NetPacketReader reader,
|
||||||
|
byte channel,
|
||||||
|
DeliveryMethod deliveryMethod)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReadOnlySpan<byte> payload = reader.GetRemainingBytesSpan();
|
||||||
|
if (payload.Length is < 9 or > 64)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string message = Encoding.ASCII.GetString(payload);
|
||||||
|
if (_host && TryNonce(message, PingPrefix, out string? pingNonce))
|
||||||
|
{
|
||||||
|
_hostNonces[peer] = pingNonce!;
|
||||||
|
Send(peer, EchoPrefix + pingNonce);
|
||||||
|
}
|
||||||
|
else if (_host
|
||||||
|
&& _hostNonces.TryGetValue(peer, out string? hostNonce)
|
||||||
|
&& string.Equals(message, AckPrefix + hostNonce, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_hostNonces.Remove(peer);
|
||||||
|
Send(peer, DonePrefix + hostNonce);
|
||||||
|
ExchangeCompleted?.Invoke(peer);
|
||||||
|
_completed.TrySetResult(true);
|
||||||
|
}
|
||||||
|
else if (!_host
|
||||||
|
&& _nonce is not null
|
||||||
|
&& string.Equals(message, EchoPrefix + _nonce, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Send(peer, AckPrefix + _nonce);
|
||||||
|
}
|
||||||
|
else if (!_host
|
||||||
|
&& _nonce is not null
|
||||||
|
&& string.Equals(message, DonePrefix + _nonce, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
ExchangeCompleted?.Invoke(peer);
|
||||||
|
_completed.TrySetResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
reader.Recycle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNonce(string message, string prefix, out string? nonce)
|
||||||
|
{
|
||||||
|
nonce = null;
|
||||||
|
if (!message.StartsWith(prefix, StringComparison.Ordinal)
|
||||||
|
|| message.Length != prefix.Length + 32)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
string candidate = message[prefix.Length..];
|
||||||
|
if (!candidate.All(static character => character is >= '0' and <= '9'
|
||||||
|
or >= 'a' and <= 'f'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
nonce = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Send(NetPeer peer, string message) => peer.Send(
|
||||||
|
Encoding.ASCII.GetBytes(message),
|
||||||
|
DeliveryMethod.ReliableOrdered);
|
||||||
|
|
||||||
|
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) =>
|
||||||
|
_hostNonces.Remove(peer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.TestClient;
|
||||||
|
|
||||||
|
internal sealed class HostServiceFailureBudget
|
||||||
|
{
|
||||||
|
private const int MaximumConsecutiveTransientFailures = 3;
|
||||||
|
private int _consecutiveTransientFailures;
|
||||||
|
|
||||||
|
internal bool ShouldStop(
|
||||||
|
RendezvousErrorCode error,
|
||||||
|
DateTimeOffset leaseExpiresAt,
|
||||||
|
DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (error == RendezvousErrorCode.None)
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!IsTransient(error))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_consecutiveTransientFailures++;
|
||||||
|
return _consecutiveTransientFailures >= MaximumConsecutiveTransientFailures
|
||||||
|
|| now >= leaseExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Reset() => _consecutiveTransientFailures = 0;
|
||||||
|
|
||||||
|
private static bool IsTransient(RendezvousErrorCode error) => error is
|
||||||
|
RendezvousErrorCode.RateLimited
|
||||||
|
or RendezvousErrorCode.ServiceUnavailable
|
||||||
|
or RendezvousErrorCode.InternalError;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user