Compare commits

...

26 Commits

Author SHA1 Message Date
KyuubiYoru 06c4ecf8f3 feat(browser): stream bounded live session updates (#26)
quality-gate / quality (push) Failing after 1m47s
quality-gate / container (push) Has been skipped
2026-07-16 23:25:48 +02:00
KyuubiYoru 95c3a4aed6 docs(operations): record v1 readiness evidence (#23)
quality-gate / quality (push) Failing after 1m29s
quality-gate / container (push) Has been skipped
2026-07-16 22:39:50 +02:00
KyuubiYoru 00d5ff7764 feat(operations): add production readiness gate (#23) 2026-07-16 22:19:51 +02:00
KyuubiYoru 6bad659c12 docs(integration): record Unscouted pilot evidence (#22)
quality-gate / quality (push) Failing after 1m35s
quality-gate / container (push) Has been skipped
2026-07-16 21:53:48 +02:00
KyuubiYoru f368fec6eb feat(deploy): provision Unscouted smoke tenant (#22) 2026-07-16 21:49:17 +02:00
KyuubiYoru 9e863ebf64 docs(integration): verify Godot and Linux SpaceGame pilot (#21)
quality-gate / quality (push) Failing after 1m40s
quality-gate / container (push) Has been skipped
2026-07-16 20:32:53 +02:00
KyuubiYoru ebb5eb617c docs(integration): record SpaceGame pilot checkpoint (#21)
quality-gate / quality (push) Failing after 1m26s
quality-gate / container (push) Has been skipped
2026-07-16 19:18:03 +02:00
KyuubiYoru 7fb85059fb docs: add integration guides and incident runbooks (#20)
quality-gate / quality (push) Failing after 1m28s
quality-gate / container (push) Has been skipped
2026-07-16 18:25:10 +02:00
KyuubiYoru cc5793f935 feat(release): add reproducible signed artifacts (#19)
quality-gate / quality (push) Failing after 1m50s
quality-gate / container (push) Has been skipped
2026-07-16 17:48:21 +02:00
KyuubiYoru 07004cd75f docs(evidence): record clean capacity candidate (#18)
quality-gate / quality (push) Failing after 1m28s
quality-gate / container (push) Has been skipped
2026-07-16 16:11:39 +02:00
KyuubiYoru cf14836d48 fix(operations): require clean candidate provenance (#18) 2026-07-16 16:04:25 +02:00
KyuubiYoru 609dad7cf1 feat(operations): add capacity and resilience gates (#18) 2026-07-16 15:57:01 +02:00
KyuubiYoru 08729ae25c feat(deployment): add secure Linux runtime (#17)
quality-gate / quality (push) Failing after 1m9s
quality-gate / container (push) Has been skipped
2026-07-16 15:03:04 +02:00
KyuubiYoru be732de7c9 feat(server): add observability and operator controls (#16)
quality-gate / quality (push) Failing after 1m1s
2026-07-16 13:22:16 +02:00
KyuubiYoru 88ef946af5 feat(server): harden hostile input and overload behavior (#15)
quality-gate / quality (push) Failing after 1m5s
2026-07-16 12:37:38 +02:00
KyuubiYoru 2ff7cd6d9d test(integration): add deterministic NAT topology harness (#14)
quality-gate / quality (push) Failing after 1m3s
2026-07-16 11:50:53 +02:00
KyuubiYoru 7e3be2cad1 feat(tooling): add standalone rendezvous test client (#25)
quality-gate / quality (push) Failing after 1m3s
2026-07-16 11:05:56 +02:00
KyuubiYoru 94aba8a3bb feat(client): standardize connection outcomes (#13)
quality-gate / quality (push) Successful in 59s
2026-07-16 10:18:41 +02:00
KyuubiYoru b4b6072fe1 feat(client): add rendezvous traversal coordinators (#12)
quality-gate / quality (push) Successful in 56s
2026-07-16 08:39:05 +02:00
KyuubiYoru 6d076c281a feat: implement authenticated NAT mediator (#11)
quality-gate / quality (push) Successful in 59s
Closes #11
2026-07-16 07:37:02 +02:00
KyuubiYoru 1baa1055dc feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s
Closes #10
2026-07-16 06:56:30 +02:00
KyuubiYoru 06c3973ce7 feat: add publisher and browser client SDK (#9)
quality-gate / quality (push) Successful in 1m6s
Closes #9
2026-07-16 06:27:44 +02:00
KyuubiYoru a9a2b3db35 feat: add bounded compatible session browser (#8)
quality-gate / quality (push) Successful in 57s
Closes #8
2026-07-16 06:06:29 +02:00
KyuubiYoru 49564c7e7e feat: add presence-gated session leases (#7)
quality-gate / quality (push) Successful in 55s
Closes #7
2026-07-16 05:58:47 +02:00
KyuubiYoru 02ca502a76 feat: add atomic ephemeral state (#6)
quality-gate / quality (push) Successful in 57s
Closes #6
2026-07-16 05:32:48 +02:00
KyuubiYoru 47382ddadc feat: add tenant provisioning and key lifecycle (#5)
quality-gate / quality (push) Successful in 50s
Closes #5
2026-07-16 05:13:35 +02:00
220 changed files with 38469 additions and 229 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitea
.idea
.vs
.codex
.agents
**/bin
**/obj
TestResults
deploy/compose/secrets
deploy/compose/.smoke.env
docs
tests
+136 -2
View File
@@ -14,16 +14,34 @@ jobs:
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Install .NET SDK
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.301
- name: Restore locked dependencies
run: dotnet restore Rendezvous.slnx --locked-mode
- name: Verify dependency licenses and reviewed transport pin
run: python3 eng/release_artifacts.py policy --root .
- name: Reject vulnerable direct or transitive packages
shell: bash
run: |
set -euo pipefail
dotnet package list --project Rendezvous.slnx \
--vulnerable --include-transitive --no-restore --format json \
>"${RUNNER_TEMP}/nuget-vulnerabilities.json"
python3 eng/release_artifacts.py audit \
--input "${RUNNER_TEMP}/nuget-vulnerabilities.json"
- name: Enforce compatibility version bumps
run: ./scripts/check-compatibility.sh origin/main
- name: Verify formatting and analyzers
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
@@ -35,3 +53,119 @@ jobs:
- name: Test
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET SDK
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
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
export SOURCE_REVISION_ID="$GITHUB_SHA"
docker compose -f "$compose_file" build \
--build-arg SOURCE_REVISION_ID="$SOURCE_REVISION_ID"
docker compose -f "$compose_file" up --no-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"
+193
View File
@@ -0,0 +1,193 @@
name: immutable-release
on:
push:
tags:
- "v*.*.*"
concurrency:
group: release-${{ gitea.ref_name }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 45
environment: production
steps:
- name: Check out immutable tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Install pinned .NET SDK
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.301
- name: Install pinned Buildx and BuildKit
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
with:
version: v0.35.0
install: true
driver-opts: image=moby/buildkit:v0.25.2@sha256:0f63d66f8d2de0bd16438284831a3e9ee6ca7cd57b6eb3ed6e38a7a456590fa7
- name: Validate tag and produce reproducible artifacts
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
./scripts/check-release-tag.sh "$GITHUB_REF_NAME"
previous_tag="$(git tag --merged HEAD^ --list 'v*.*.*' --sort=-version:refname | sed -n '1p')"
if [[ -n "$previous_tag" ]]; then
./scripts/check-compatibility.sh "$previous_tag"
elif [[ -n "$(git tag --list 'v*.*.*' | sed -n '1p')" ]]; then
echo "No prior release tag is an ancestor of $GITHUB_REF_NAME." >&2
exit 1
else
./scripts/check-compatibility.sh __initial_release_without_base__
fi
release_builder="rendezvous-release-builder:${GITHUB_SHA}"
docker buildx build \
--platform linux/amd64 \
--file eng/release-builder.Dockerfile \
--target release-builder \
--load \
--tag "$release_builder" .
mkdir -p "${RUNNER_TEMP}/release-home" "${RUNNER_TEMP}/nuget"
docker run --rm \
--user "$(id -u):$(id -g)" \
--env HOME="${RUNNER_TEMP}/release-home" \
--env NUGET_PACKAGES="${RUNNER_TEMP}/nuget" \
--volume "$GITHUB_WORKSPACE:/source" \
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
--workdir /source \
"$release_builder" \
./scripts/build-release.sh "$version" "${RUNNER_TEMP}/release/$version"
./scripts/verify-real-consumers.sh "$version" "${RUNNER_TEMP}/release/$version"
echo "RENDEZVOUS_VERSION=$version" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_DIR=${RUNNER_TEMP}/release/$version" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_BUILDER=$release_builder" >>"$GITHUB_ENV"
- name: Build exact container candidate
shell: bash
run: |
set -euo pipefail
export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
common=(
--no-cache
--pull=false
--provenance=false
--platform linux/amd64
--build-arg SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH"
--build-arg SOURCE_REVISION_ID="$GITHUB_SHA"
)
release_tag="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
image_one="${RUNNER_TEMP}/rendezvous-image-1.tar"
image_two="${RUNNER_TEMP}/rendezvous-image-2.tar"
docker buildx build "${common[@]}" --tag "$release_tag" \
--output "type=docker,dest=$image_one,rewrite-timestamp=true" .
docker buildx build "${common[@]}" --tag "$release_tag" \
--output "type=docker,dest=$image_two,rewrite-timestamp=true" .
cmp --silent "$image_one" "$image_two"
docker load --input "$image_one"
candidate_id="$(docker image inspect --format '{{.Id}}' "$release_tag")"
buildkit_version="$(docker buildx inspect --bootstrap | sed -n 's/.*BuildKit version: *//p' | sed -n '1p')"
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$GITHUB_WORKSPACE:/source" \
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
--workdir /source \
"$RENDEZVOUS_RELEASE_BUILDER" \
python3 eng/release_artifacts.py record-container-build \
--provenance "$RENDEZVOUS_RELEASE_DIR/release-provenance.json" \
--buildx-version "$(docker buildx version)" \
--buildkit-version "$buildkit_version" \
--image-id "$candidate_id"
- name: Stage HTTP registration, browse, and authenticated UDP traversal
shell: bash
run: |
set -euo pipefail
secret="deploy/compose/secrets/signing-key"
cleanup() {
RENDEZVOUS_UID=1654 RENDEZVOUS_GID=1654 RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}" \
docker compose -f deploy/compose/compose.yaml 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
export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
docker compose -f deploy/compose/compose.yaml up --detach --no-build
for attempt in {1..100}; do
curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1 && break
if (( attempt == 100 )); then
docker compose -f deploy/compose/compose.yaml logs rendezvous
exit 1
fi
sleep 0.1
done
./scripts/smoke-deployment.sh
- name: Scan candidate for high and critical vulnerabilities
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
with:
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
version: v0.69.3
format: table
exit-code: "1"
ignore-unfixed: false
severity: HIGH,CRITICAL
- name: Generate container SPDX inventory
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
with:
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
version: v0.69.3
format: spdx-json
output: ${{ env.RENDEZVOUS_RELEASE_DIR }}/FinalFactory.Rendezvous.Container.${{ env.RENDEZVOUS_VERSION }}.spdx.json
- name: Finalize checksums over the publish-ready candidate
shell: bash
run: |
set -euo pipefail
source_date_epoch="$(git show -s --format=%ct HEAD)"
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$GITHUB_WORKSPACE:/source" \
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
--workdir /source \
"$RENDEZVOUS_RELEASE_BUILDER" \
bash -c 'python3 eng/release_artifacts.py normalize-container-sbom \
--file "$1/FinalFactory.Rendezvous.Container.$2.spdx.json" \
--version "$2" \
--commit "$3" \
--source-date-epoch "$4" \
&& ./scripts/finalize-release-candidate.sh "$2" "$1"' \
_ "$RENDEZVOUS_RELEASE_DIR" "$RENDEZVOUS_VERSION" "$GITHUB_SHA" "$source_date_epoch"
- name: Preserve verified candidate artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: rendezvous-${{ env.RENDEZVOUS_VERSION }}
path: ${{ env.RENDEZVOUS_RELEASE_DIR }}
if-no-files-found: error
retention-days: 30
- name: Install pinned signing client
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.0.6
- name: Publish once, sign, attest, and create release
shell: bash
env:
RENDEZVOUS_RELEASE_USERNAME: ${{ secrets.RELEASE_USERNAME }}
RENDEZVOUS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: ./scripts/publish-release.sh "$RENDEZVOUS_VERSION" "$RENDEZVOUS_RELEASE_DIR"
+6
View File
@@ -6,3 +6,9 @@ TestResults/
*.suo
*.user
*.userosscache
deploy/compose/.smoke.env
artifacts/
__pycache__/
*.pyc
deploy/compose/secrets/*
!deploy/compose/secrets/.gitignore
+25
View File
@@ -0,0 +1,25 @@
# Changelog
All notable Rendezvous release changes are recorded here. Versions follow
Semantic Versioning; HTTP, UDP, and connection-ticket format compatibility is
tracked separately and called out for every release.
## 1.0.0 - 2026-07-16
### Compatibility
- Initial Client and Contracts package major: 1.
- HTTP contract: v1; UDP mediation contract: v1; connection-ticket format: v1.
- Server accepts Client 1.0.0 through the latest compatible 1.x release.
- Client traversal is pinned to LiteNetLib 2.1.4; LiteNetLib 1.x is unsupported.
### Security and configuration
- Packages contain no reusable credentials or environment configuration.
- Production server startup requires provisioned signing keys and the hardened
single-active deployment configuration.
### Migration
- This is the first packaged release; no prior package or wire migration exists.
- Consumers must pin both Rendezvous packages to the same exact version.
+28
View File
@@ -1,4 +1,5 @@
<Project>
<Import Project="eng/Versions.props" />
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
@@ -8,8 +9,35 @@
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<Version>$(RendezvousVersion)</Version>
<PackageVersion Condition="'$(PackageVersion)' == ''">$(RendezvousVersion)</PackageVersion>
<AssemblyVersion>$(RendezvousMajorVersion).0.0.0</AssemblyVersion>
<FileVersion>$(RendezvousMajorVersion).$(RendezvousMinorVersion).$(RendezvousPatchVersion).0</FileVersion>
<Authors>Final Factory</Authors>
<Company>Final Factory</Company>
<RepositoryUrl>https://git.finalfactory.de/HeiKyu/Rendezvous</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://git.finalfactory.de/HeiKyu/Rendezvous</PackageProjectUrl>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<EnableSourceLink>true</EnableSourceLink>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageReleaseNotes>See CHANGELOG.md in the package and repository.</PackageReleaseNotes>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<Target Name="ConfigureGiteaSourceLink"
BeforeTargets="_GenerateSourceLinkFile"
DependsOnTargets="InitializeSourceControlInformation">
<ItemGroup>
<SourceRoot Update="@(SourceRoot)"
SourceLinkUrl="$(RepositoryUrl)/raw/commit/$(SourceRevisionId)/*" />
</ItemGroup>
</Target>
</Project>
+1 -1
View File
@@ -4,7 +4,7 @@
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="LiteNetLib" Version="2.1.4" />
<PackageVersion Include="LiteNetLib" Version="[$(LiteNetLibVersion)]" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build
ARG SOURCE_REVISION_ID
WORKDIR /source
COPY Directory.Build.props Directory.Packages.props NuGet.config global.json Rendezvous.slnx ./
COPY eng/Versions.props eng/Versions.props
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 \
/p:ContinuousIntegrationBuild=true \
/p:RepositoryCommit="$SOURCE_REVISION_ID" \
/p:SourceRevisionId="$SOURCE_REVISION_ID"
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"]
+57 -3
View File
@@ -17,7 +17,7 @@ Rendezvous is intended to provide:
- Isolation by game, environment, protocol version, and region.
- Operational health, metrics, logging, administration, and rate limiting.
UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server or a future relay service.
UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server. The v1 SDK returns an optional game-configured endpoint for an explicit caller decision; it never routes automatically, and v1 does not provide a relay.
## Connection flow
@@ -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.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
@@ -75,12 +79,56 @@ The initial service does not provide:
## Project status
Rendezvous is currently in its initial design and bootstrap stage. The first implementation should establish the contracts, directory leases, LiteNetLib mediator, client SDK, thin test client, and a three-party integration test before either game depends on it for production connectivity.
Rendezvous is under active roadmap development. The versioned contracts,
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
SDK coordination, typed connection outcomes, 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, and reproducible signed release pipeline are
implemented. 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
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
The frozen v1 wire surface is documented in the
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
Tenant policy, publisher/operator principals, and production key custody are
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
Layered HTTP/UDP budgets, overload behavior, and safe operational tuning are
defined in [hostile-input and overload protection](docs/security/abuse-protection.md).
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
operator controls are defined in the
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
Concrete detect/contain/recover/verify procedures are in the
[incident and change runbooks](docs/operations/incident-runbooks.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).
Release versions, compatibility windows, immutable artifact construction,
signing, staged promotion, rollback, and migration are defined in
[releases and compatibility](docs/releases/README.md).
The scriptable host/browser/join diagnostic and its stable automation contract are
documented in the [TestClient integration guide](docs/integration/test-client.md).
Optional bounded SSE deltas, reconnect/reset semantics, proxy requirements, and
polling fallback are documented in
[live session-list updates](docs/integration/live-session-updates.md).
The package, gameplay-socket, host-admission, provisioning, metadata, key rotation,
versioning, and secure rollout seams are in the
[game integration guide](docs/integration/sdk-seams.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).
The current consumer evidence and remaining external gates are tracked in the
[SpaceGame consumer pilot](docs/integration/spacegame-pilot.md) and independent
[Unscouted consumer pilot](docs/integration/unscouted-pilot.md).
The fail-closed launch decision, redacted evidence matrix, and two-machine
external-network procedure are in
[production readiness and real-network canary](docs/operations/production-readiness.md).
## Development
@@ -97,5 +145,11 @@ dotnet test Rendezvous.slnx --configuration Release --no-build
Run the bootstrap server with
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
the configured UDP mediator port; both stop through normal host cancellation.
The launch profile uses separate ephemeral development-only publisher and operator
signing keys. Production
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.
The project dependency rules and supported runtime choices are documented in
[project and dependency boundaries](docs/architecture/project-boundaries.md).
+1
View File
@@ -6,6 +6,7 @@
<Project Path="src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/FinalFactory.Rendezvous.Capacity/FinalFactory.Rendezvous.Capacity.csproj" />
<Project Path="tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,91 @@
{
"AllowedHosts": "localhost;127.0.0.1;rendezvous",
"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"
},
{
"KeyId": "local-smoke-unscouted-1",
"SecretReference": "file:/run/secrets/rendezvous-signing-key",
"CredentialKinds": ["DedicatedPublisher"],
"GameId": "unscouted",
"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, 2],
"Regions": ["local"],
"VisibilityModes": ["Public"],
"PublisherTrustModes": ["ManagedDedicated"],
"MetadataValueMaxBytes": {
"mode": 32
},
"RequiredMetadataKeys": [],
"MetadataMaxBytes": 512,
"MetadataMaxKeys": 1,
"MaxListingsPerPrincipal": 10,
"MaxAnonymousListingsPerAddress": 0,
"MaxActiveJoinAttempts": 100,
"FallbackPolicy": "DedicatedEndpointAllowed"
},
{
"GameId": "unscouted",
"EnvironmentId": "smoke",
"Enabled": true,
"ProtocolVersions": [1],
"Regions": ["local"],
"VisibilityModes": ["Public"],
"PublisherTrustModes": ["ManagedDedicated"],
"MetadataValueMaxBytes": {
"mode": 32,
"world": 64,
"mods": 64
},
"RequiredMetadataKeys": ["mode", "world", "mods"],
"MetadataMaxBytes": 512,
"MetadataMaxKeys": 3,
"MaxListingsPerPrincipal": 10,
"MaxAnonymousListingsPerAddress": 0,
"MaxActiveJoinAttempts": 100,
"FallbackPolicy": "DedicatedEndpointAllowed"
}
]
}
}
}
+35
View File
@@ -0,0 +1,35 @@
name: rendezvous-local
services:
rendezvous:
image: "${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"
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+48
View File
@@ -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
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 |
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
| 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 |
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
| Host presence freshness | 20 seconds |
@@ -129,9 +129,18 @@ until the owner records:
- which games may enable anonymous unlisted player hosting;
- deployment regions, data-processing jurisdiction, and approval of the stated
30-day audit/13-month aggregate retention periods;
- 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.
- the per-game dedicated fallback endpoint policy.
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
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 1100, 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`, `15s`, `515s`, `1530s`, or `30s+`). They contain no
diagnostic message, exact duration, endpoint, metadata, player identifier, or
credential.
Frozen v1 DTOs still expose `elapsedMilliseconds` and `diagnosticCode`. They are
deprecated compatibility inputs: the current SDK omits them, the service
immediately buckets legacy elapsed time, and neither exact timing nor diagnostic
text is retained, logged, or used as a metric dimension.
The store retains a bounded capability-fingerprint tombstone long enough to
accept a report after the live attempt expires. Metrics count the first accepted
outcome only and use only outcome plus elapsed bucket as dimensions. Service
issuance failures cannot be reported because no attempt capability was issued.
## Consequences
- Player-facing UI can map stable outcome/category pairs to localized guidance
without exposing diagnostic strings.
- Service rejection, remote-host rejection, and local observation remain
distinguishable for retry and support decisions.
- Games own fallback policy and gameplay admission; Rendezvous does not claim a
guaranteed connection path.
- Outcome additions are contract changes and require OpenAPI, serialization,
public API, fake-clock, late-event, and idempotency coverage.
+8
View File
@@ -6,9 +6,17 @@ 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 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 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)
- [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md)
- [Game provisioning and signing-key lifecycle](../security/provisioning.md)
These decisions intentionally leave gameplay authority, player identity,
simulation, persistence, social features, skill matchmaking, and gameplay
+4 -2
View File
@@ -14,5 +14,7 @@ The public .NET types live in `FinalFactory.Rendezvous.Contracts`, target
vectors and a public-API snapshot make accidental wire or source compatibility
changes fail the normal test gate.
Any incompatible change requires a new contract version. Additive JSON fields
may be introduced within v1 because v1 readers ignore unknown object members.
Readers ignore unknown JSON members, but the release gate deliberately treats
any accepted OpenAPI or golden JSON surface drift as a contract-version change.
That conservative policy makes additive and incompatible published changes
equally visible to consumers instead of relying on an undocumented minor shape.
+27 -5
View File
@@ -33,15 +33,14 @@ the same value as a required query parameter.
| `GET` | `/v1/sessions` | Browse compatible public sessions. |
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
| `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. |
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
| `GET` | `/health/live` | Report that the HTTP process is alive. |
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
shape reference for parameters, bodies, and responses. Contract-only endpoints
return `501` until their behavior is implemented by the subsequent directory,
lease, and join-orchestration issues.
shape reference for parameters, bodies, and responses.
Host polling sends its reusable lease credential in
`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
@@ -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
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
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` |
| 403 | `forbidden` |
| 404 | `notFound` |
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` |
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected` |
| 410 | `expired`, `staleHost` |
| 429 | `rateLimited` (with retry guidance when known) |
| 429 | `rateLimited`, `capacityExceeded` (with retry guidance when known) |
| 503 | `serviceUnavailable` (with retry guidance when known) |
| 500 | `internalError` |
+51 -8
View File
@@ -1,11 +1,11 @@
# UDP presence contract v1
# UDP presence and NAT-punch contract v1
Tracking: #4
Tracking: #4, #11
The UDP mediator accepts a single bounded presence envelope from a host or
client. It associates the authenticated mediation handle with the packet's
observed public source endpoint and the sender's reported local endpoint. It
does not carry gameplay packets.
The UDP mediator accepts the frozen bounded presence envelope below and native
LiteNetLib NAT-introduction requests. Both forms associate an authenticated
mediation handle with the packet's observed public source endpoint and the
sender's reported local endpoint. Neither form carries gameplay packets.
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
@@ -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
prove authorization until the capability is checked. Invalid packets receive
no UDP response, preventing the mediator from becoming an amplification oracle.
Replay, expiry, pairing, and rate-limit policy are defined by later mediator
issues; the v1 envelope deliberately leaves no unbounded or reflected payload.
For the frozen envelope, `HostPresence` is resolved against either the listing's
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.
+233
View File
@@ -0,0 +1,233 @@
# 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 a local/private-bridge smoke
profile, not an Internet template: TCP is published only on host loopback, the
explicit `rendezvous` host name serves isolated clients on the Compose network,
and the profile deliberately opts into private advertised endpoints without a
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.
The fixed-scope helper used by the smoke can also support the manual
[TestClient local flow](../integration/test-client.md); it is deliberately not a
production issuer.
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-16T20:28:43.2873744+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": "00d5ff776408e7d80ce6648953e62a7233aca35c",
"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": 55.52666859166872,
"peakWorkingSetBytes": 176758784,
"managedBytesAfterCleanup": 35608984
},
"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.0029,
"p95Milliseconds": 0.0046,
"p99Milliseconds": 0.0055,
"operationsPerSecond": 287918.9220315559,
"minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "lease-renewal",
"samples": 1000,
"p50Milliseconds": 0.0004,
"p95Milliseconds": 0.0007,
"p99Milliseconds": 0.0019,
"operationsPerSecond": 1076426.264800861,
"minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "visible-session-browse",
"samples": 250,
"p50Milliseconds": 1.0232,
"p95Milliseconds": 3.6083,
"p99Milliseconds": 4.2925,
"operationsPerSecond": 650.0325926341947,
"minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "join-attempt-issuance",
"samples": 1000,
"p50Milliseconds": 0.0028,
"p95Milliseconds": 0.0042,
"p99Milliseconds": 0.0052,
"operationsPerSecond": 135253.93927098127,
"minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "simultaneous-punch-pairing",
"samples": 1000,
"p50Milliseconds": 0.0039,
"p95Milliseconds": 0.0069,
"p99Milliseconds": 0.0087,
"operationsPerSecond": 110619.46902654869,
"minimumOperationsPerSecond": 2000,
"budgetMilliseconds": 100,
"passed": true
},
{
"operation": "principal-revocation",
"samples": 50,
"p50Milliseconds": 0.495,
"p95Milliseconds": 0.6508,
"p99Milliseconds": 11.011,
"operationsPerSecond": 1393.258301729591,
"minimumOperationsPerSecond": 50,
"budgetMilliseconds": 200,
"passed": true
},
{
"operation": "telemetry-recording",
"samples": 1000,
"p50Milliseconds": 0.0001,
"p95Milliseconds": 0.0001,
"p99Milliseconds": 0.0001,
"operationsPerSecond": 1479289.9408284025,
"minimumOperationsPerSecond": 10000,
"budgetMilliseconds": 1,
"passed": true
},
{
"operation": "coincident-listing-attempt-expiry",
"samples": 1,
"p50Milliseconds": 27.7056,
"p95Milliseconds": 27.7056,
"p99Milliseconds": 27.7056,
"operationsPerSecond": 36.093525543388026,
"minimumOperationsPerSecond": 0,
"budgetMilliseconds": 200,
"passed": true
}
],
"state": {
"peakListings": 25000,
"peakAttempts": 10000,
"peakReplayMarkers": 0,
"finalListings": 0,
"finalAttempts": 0,
"finalReplayMarkers": 0,
"expiryChurn": 94906,
"maintenanceSweeps": 36307,
"soakCyclesCompleted": 77547145,
"soakDurationSeconds": 300.0000041,
"soakPeakScheduledExpiryEntries": 7,
"soakManagedGrowthBytes": -263432,
"soakHandleGrowth": 2,
"restartStartedEmpty": true,
"overloadWasTyped": true,
"recoverySucceeded": true
},
"failures": [],
"passed": true
}
+86
View File
@@ -0,0 +1,86 @@
{
"schemaVersion": "1.0",
"recordedAt": "2026-07-16",
"issue": 21,
"consumerIssue": "Kyuubi/SpaceGame#3",
"result": "checkpoint-pass-with-external-gates",
"rendezvousBaseCommit": "ebb5eb617c0bbb170418afab396b68584b7f992e",
"consumerCommit": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
"consumerIssueComment": 11469,
"packages": {
"FinalFactory.Rendezvous.Client": {
"version": "1.0.0",
"source": "local-candidate",
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
},
"FinalFactory.Rendezvous.Contracts": {
"version": "1.0.0",
"source": "local-candidate",
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
},
"LiteNetLib": {
"version": "2.1.4"
}
},
"localRun": {
"processes": ["Rendezvous", "Godot SpaceGame host", "two sequential Godot SpaceGame clients"],
"typedOutcome": "Connected",
"gameAdmission": "Accepted",
"directGameplay": true,
"authenticatedSessions": 2,
"directInputs": 2,
"directSnapshots": 2,
"lifecyclePackets": 4,
"gameplayTransport": "caller-owned-litenetlib",
"rendezvousGameplayPayloadPath": "none",
"hostLeaseRenewed": true,
"reconnected": true,
"deregistered": true
},
"linuxRun": {
"runtime": "Godot 4.7 .NET Linux x86_64",
"freshExport": true,
"sourceDirty": false,
"optimized": true,
"dedicatedHostNamespace": "docker",
"remoteClientNamespace": "docker",
"topology": "private-bridge",
"directGameplay": true,
"fallback": "PunchTimedOut to explicit Docker-gateway endpoint, then Accepted game admission and direct gameplay",
"artifactHashes": "SpaceGame issue #3 comment 11469"
},
"negativePaths": {
"incompatibleProtocol": "proven",
"staleHostPresence": "proven",
"punchTimeout": "proven",
"invalidAdmission": "integration-proven",
"capacity": "regression-tested",
"fallbackConnection": "godot-and-isolated-linux-proven",
"reconnect": "proven"
},
"verification": {
"debugBuild": "passed",
"releaseBuild": "passed",
"debugTests": { "passed": 31, "failed": 0 },
"releaseTests": { "passed": 31, "failed": 0 },
"exportRelease": "optimized-without-debug-symbols",
"format": "passed",
"shellcheck": "passed",
"godotReconnectHarness": "passed",
"godotFallbackHarness": "passed",
"linuxContainerHarness": "passed-clean-source",
"failureMatrix": "passed",
"adversarialReview": "passed-after-fixes"
},
"openGates": [
"public-package-restore",
"representative-external-nat"
],
"relatedSpaceGameGates": [
"production-enet-replacement",
"capacity-profiles-64-and-128",
"sigterm-drain-save"
]
}
+82
View File
@@ -0,0 +1,82 @@
{
"schemaVersion": "1.0",
"recordedAt": "2026-07-16",
"issue": 22,
"consumerIssue": "HeiKyu/Unscouted#459",
"result": "checkpoint-pass-with-external-gates",
"rendezvousConfigurationCommit": "f368fec6eb4344a6042974f58f888cf0f1ac8e8e",
"consumerImplementationCommit": "1e5886aa7f1e44689b4c75e32693eb7b19fd72d7",
"consumerEvidenceCommit": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
"consumerIssueComment": 11499,
"packages": {
"FinalFactory.Rendezvous.Client": {
"version": "1.0.0",
"source": "local-candidate",
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
},
"FinalFactory.Rendezvous.Contracts": {
"version": "1.0.0",
"source": "local-candidate",
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
},
"LiteNetLib": {
"version": "2.1.4"
}
},
"configuration": {
"gameId": "unscouted",
"environmentId": "smoke",
"regionId": "local",
"protocolVersion": 1,
"publisherTrust": "ManagedDedicated",
"fallbackPolicy": "DedicatedEndpointAllowed",
"metadataKeys": ["mode", "world", "mods"],
"metadataMaxKeys": 3,
"metadataMaxBytes": 512
},
"godotRun": {
"runtime": "Godot 4.7 .NET Linux x86_64",
"processes": [
"Rendezvous hardened Compose service",
"Godot Unscouted host",
"Godot incompatible-protocol client",
"Godot direct client",
"Godot fallback client"
],
"gameplayTransport": "unscouted-litenetlib",
"rendezvousGameplayPayloadPath": "none",
"directGameplay": true,
"fallbackGameplay": true,
"authenticatedSessions": 2,
"gameplayExchanges": 2,
"hostLeaseRenewed": true,
"deregistered": true,
"playerIdentityOwner": "unscouted",
"canonicalGameStateOwner": "unscouted"
},
"negativePaths": {
"incompatibleProtocol": "proven-no-compatible-listing",
"wrongGame": "proven-exact-NotFound",
"wrongEnvironment": "proven-exact-NotFound",
"punchTimeout": "proven-typed-failure-then-game-owned-fallback",
"unexpectedMetadata": "consumer-regression-tested"
},
"verification": {
"rendezvousDebugTests": { "passed": 299, "failed": 0 },
"rendezvousReleaseTests": { "passed": 299, "failed": 0 },
"consumerDebugTests": { "passed": 3310, "skipped": 15, "failed": 0 },
"consumerReleaseTests": { "passed": 3310, "skipped": 15, "failed": 0 },
"consumerGdUnitTests": { "passed": 360, "skipped": 0, "failed": 0 },
"consumerExport": "not-applicable-no-export-presets",
"format": "passed",
"shellcheck": "passed",
"godotPilot": "passed",
"adversarialReview": "passed-after-fixes"
},
"openGates": [
"public-package-restore",
"representative-external-nat"
]
}
+124
View File
@@ -0,0 +1,124 @@
{
"schemaVersion": 1,
"kind": "rendezvous-production-readiness",
"evaluatedCommit": "00d5ff776408e7d80ce6648953e62a7233aca35c",
"decision": "not-ready",
"localGates": [
{
"id": "immutable-release-artifacts",
"status": "pass",
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
"note": "Clean candidate packages and server archive are byte reproducible and fully verified."
},
{
"id": "debug-and-release-verification",
"status": "pass",
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
"note": "All 300 tests pass in Debug and Release; the Release build has zero warnings and errors."
},
{
"id": "real-consumer-pilots",
"status": "pass",
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
"note": "Pinned real projects restore the candidate and both game launch pilots pass direct traffic."
},
{
"id": "candidate-capacity-resilience",
"status": "pass",
"evidenceRef": "docs/evidence/capacity/v2/candidate-2cpu.json",
"note": "The clean two-CPU five-minute candidate passes all budgets with zero retained state."
},
{
"id": "production-process-recovery",
"status": "pass",
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
"note": "All selected restart, drain, socket release, overload, and recovery tests pass."
},
{
"id": "security-privacy-observability",
"status": "pass",
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
"note": "The complete security, privacy, health, audit, telemetry, and release suite passes."
}
],
"externalGates": [
{
"id": "public-package-empty-cache-restore",
"status": "pending",
"evidenceRef": "docs/operations/production-readiness.md",
"note": "The public registry does not currently resolve version 1.0.0."
},
{
"id": "signed-publication",
"status": "pending",
"evidenceRef": "docs/releases/README.md",
"note": "Protected release credentials and immutable tag publication are required."
},
{
"id": "source-preserving-udp-ingress",
"status": "pending",
"evidenceRef": "docs/operations/production-readiness.md",
"note": "The public ingress path needs packet-level source and reply validation."
},
{
"id": "same-lan-direct-canary",
"status": "pending",
"evidenceRef": "docs/operations/production-readiness.md",
"note": "Requires two independently operated game clients."
},
{
"id": "home-nat-direct-canary",
"status": "pending",
"evidenceRef": "docs/operations/production-readiness.md",
"note": "Requires distinct residential networks."
},
{
"id": "restrictive-cgnat-typed-failure",
"status": "pending",
"evidenceRef": "docs/operations/production-readiness.md",
"note": "Requires a known restrictive carrier topology."
},
{
"id": "firewall-blocked-udp-typed-failure",
"status": "pending",
"evidenceRef": "docs/operations/production-readiness.md",
"note": "Requires an independently controlled firewall rule."
},
{
"id": "ipv6-direct-canary",
"status": "pending",
"evidenceRef": "docs/operations/production-readiness.md",
"note": "Requires two IPv6-capable external clients and public ingress."
},
{
"id": "public-rate-shaped-capacity",
"status": "pending",
"evidenceRef": "docs/operations/capacity-and-resilience.md",
"note": "The full public HTTP and UDP traffic mix has not been measured."
},
{
"id": "one-hour-candidate-endurance",
"status": "pending",
"evidenceRef": "docs/operations/capacity-and-resilience.md",
"note": "A production-shaped one-hour candidate run is required."
},
{
"id": "alert-delivery",
"status": "pending",
"evidenceRef": "docs/operations/incident-runbooks.md",
"note": "A real alert sink must observe trigger and recovery notifications."
},
{
"id": "cold-standby-rollback-drill",
"status": "pending",
"evidenceRef": "docs/operations/capacity-and-resilience.md",
"note": "The deployment must demonstrate the host-visible recovery objective."
},
{
"id": "documentation-only-runbook-exercise",
"status": "pending",
"evidenceRef": "docs/operations/incident-runbooks.md",
"note": "An independent operator must execute the runbooks using only the docs."
}
]
}
@@ -0,0 +1,58 @@
{
"schemaVersion": 1,
"kind": "rendezvous-local-release-candidate",
"version": "1.0.0",
"sourceCommit": "00d5ff776408e7d80ce6648953e62a7233aca35c",
"treeState": "clean",
"result": "pass",
"artifacts": [
{
"name": "FinalFactory.Rendezvous.Client.1.0.0.nupkg",
"sha256": "f2a4b9727b5faeba284ddcb7fc575c7495f1e29b763faababa7cd71444dc2950"
},
{
"name": "FinalFactory.Rendezvous.Contracts.1.0.0.nupkg",
"sha256": "92317f153911ebf7b8ea04cd3206ec2a17f882cb4eddb26aa8ab094ffdb06627"
},
{
"name": "FinalFactory.Rendezvous.Server.1.0.0.linux-x64.tar.gz",
"sha256": "0dab8cfc696b4a55d6ffba46286c9e532df2c943ed8fd6347fb528516156b3ba"
}
],
"verification": {
"lockedRestore": "pass",
"reportedVulnerabilities": 0,
"format": "pass",
"releaseBuildWarnings": 0,
"releaseBuildErrors": 0,
"debugTestsPassed": 300,
"debugTestsFailed": 0,
"releaseTestsPassed": 300,
"releaseTestsFailed": 0,
"selectedProductionFaultTestsPassed": 17,
"byteReproduciblePackages": "pass",
"byteReproducibleServerArchive": "pass",
"sbomChecksumsAndProvenance": "pass",
"candidateConsumerFixtures": "pass",
"realConsumerRestores": "pass"
},
"consumers": [
{
"name": "SpaceGame",
"revision": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
"candidateRestore": "pass",
"directTrafficPilot": "pass"
},
{
"name": "Unscouted",
"revision": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
"candidateRestore": "pass",
"directTrafficPilot": "pass"
}
],
"limitations": {
"publicRegistryRestore": "pending",
"signedPublication": "pending",
"externalNetworkCanaries": "pending"
}
}
+114
View File
@@ -0,0 +1,114 @@
# Live session-list updates
Tracking: #26
Live updates are an optional acceleration for an open server browser. The
bounded `GET /v1/sessions` snapshot remains the source of truth, and join
authorization still revalidates current capacity, presence, policy, and
compatibility. A displayed player count is advisory, never an admission promise.
## Snapshot, stream, reset
Every `BrowseSessionsResponse` includes `streamCursor` in addition to its normal
pagination cursor. Connect to `GET /v1/sessions/stream` with the same game,
environment, protocol, optional region, and `excludeFull` filter. Send the most
recent stream cursor as `Last-Event-ID`.
| SSE event | Contract kind | UI action |
| --- | --- | --- |
| `session_upsert` | `sessionUpsert` | Add or replace the complete public projection by listing ID. |
| `session_remove` | `sessionRemove` | Remove the listing ID. |
| `reset` | `reset` | Discard local state, fetch a fresh snapshot, then reconnect with its cursor. |
| `keepalive` | `keepalive` | Preserve the cursor and connection; do not change UI state. |
Each SSE `id` equals the opaque cursor inside its JSON event. Cursors are signed,
short-lived, monotonically ordered, and bound to the complete filter. A missing,
expired, corrupted, foreign, future, or replay-gapped cursor produces `reset`
instead of a potentially incomplete view. Do not parse or retain it as a stable
identifier.
Updates cover creation after fresh UDP presence, public-field/capacity changes,
presence staleness and recovery, lease expiry, deregistration, operator or
principal revocation, and visibility/region/protocol changes. Events contain the
same bounded public `SessionListing` as snapshots. They never contain raw peer
endpoints, lease tokens, punch capabilities, tickets, publisher subjects, or
internal store identifiers.
## SDK and polling fallback
```csharp
BrowseSessionsRequest filter = new()
{
GameId = new("space-game"),
EnvironmentId = new("production"),
ProtocolVersion = 7,
RegionId = new("eu-central"),
ExcludeFull = true,
};
RendezvousClientResult<BrowseSessionsResponse> snapshot =
await browser.BrowseAsync(filter, cancellationToken);
await foreach (RendezvousClientResult<SessionStreamEvent> update in
browser.StreamAsync(filter, snapshot.Value!.StreamCursor, cancellationToken))
{
if (!update.IsSuccess)
{
// Switch to bounded polling with jittered backoff.
break;
}
// Apply upsert/remove by listing ID. On reset, discard and browse again.
}
```
Cancellation or enumerator disposal closes the response and releases the server
subscription. A normal connection-duration close is a reconnect signal: use the
last applied event cursor. Repeated failures, unsupported platform HTTP stacks,
and restrictive proxies fall back to snapshots with exponential jittered
backoff, a capped interval, and `Retry-After`. Never open parallel streams to
compensate for a slow UI.
## TestClient
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
--configuration Release --no-build -- \
watch --service https://rendezvous.example.invalid/ \
--game space-game --environment production --region eu-central --protocol 7 \
--run-seconds 60 --json
```
`watch.snapshot`, `watch.session-upsert`, `watch.session-remove`,
`watch.keepalive`, and `watch.reconnect` are stable diagnostics. Add
`--exercise-reset --script` to corrupt the snapshot cursor deliberately and
verify a typed reset plus snapshot refresh. Use `--exercise-reconnect --script`
while producing one update to close the first stream deliberately, reconnect
from its prior cursor, and verify that the same ordered event is replayed.
Polished list diffing, selection retention, animation, and accessibility remain
in each game.
## Bounds and slow consumers
The v1 journal retains at most 4,096 public-only changes. It admits at most 256
subscribers total and 64 per tenant, reads at most 128 changes per batch,
waits a configurable 50 milliseconds after a live change and coalesces the
resulting batch to the final change per listing, sends a keepalive every 15
seconds, and closes a connection after five minutes. A consumer behind the
replay window receives `reset`; it never acquires an unbounded queue.
Normal optional-work concurrency and per-source/tenant rate controls apply for
the stream lifetime. Exhaustion returns typed HTTP `429` before streaming.
Shutdown cancels streams; reconnect only after readiness returns and expect a
reset after a single-active restart because listings and replay are ephemeral.
## Reverse proxy
- Disable response buffering (`X-Accel-Buffering: no` is also emitted),
compression, transformation, and caching for `text/event-stream`.
- Preserve `Last-Event-ID`; set upstream/read timeouts above the 15-second
keepalive and around six minutes for the five-minute connection ceiling.
- Flush events promptly and use HTTP/2 only when streaming semantics survive.
- Preserve the source-IP trust boundary and abuse controls; do not add a bypass.
Verify the deployed proxy with an idle keepalive, update, reconnect, invalid
cursor reset, slow reader, and graceful shutdown. An in-process pass does not
prove that a production proxy is non-buffering.
+232
View File
@@ -0,0 +1,232 @@
# Game integration seams
Tracking: #20
Use the [TestClient start-to-finish guide](test-client.md) before integrating a
game. It proves the service and network path without engine or game code. This
page documents only the seams that the diagnostic cannot choose for a game:
package/version policy, ownership of the gameplay socket, host admission,
metadata, credential custody, and deployment compatibility.
## Packages and compatibility
Consume `FinalFactory.Rendezvous.Client` and
`FinalFactory.Rendezvous.Contracts` from the approved Gitea NuGet source and pin
both to the same exact released version. Do not use a floating version range.
The current release matrix is machine-readable in
[`compatibility.json`](../releases/compatibility.json); the same window is
available from authenticated `GET /v1/operator/status`.
The authoritative package feed is
`https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json`. Add it to the
consumer's `NuGet.config` and map only Rendezvous packages to it; retain the
consumer's existing NuGet.org mapping for other dependencies:
```xml
<packageSources>
<add key="FinalFactory" value="https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="FinalFactory">
<package pattern="FinalFactory.Rendezvous.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
```
When the feed is anonymously readable, no reader credential is needed. If
registry policy requires authentication, use the platform's NuGet credential
provider or a protected per-user/CI NuGet configuration populated by the secret
manager. Never put a registry token in the project file, repository,
package-source URL, or `dotnet` command argument.
```xml
<ItemGroup>
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="1.0.0" />
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="1.0.0" />
</ItemGroup>
```
Run `dotnet restore`, then `dotnet list package --include-transitive` and verify
that Client and Contracts resolve to the same exact version and LiteNetLib to the
release matrix version before compiling the game.
Release 1.0.0 targets `netstandard2.1`, requires LiteNetLib `2.1.4`, speaks HTTP,
UDP, and connection-ticket contract version `1`, and requires an exact
tenant-configured gameplay protocol match. A package patch does not silently
change a wire version. Follow the [release and migration policy](../releases/README.md)
when changing any dimension, and validate the generated
[OpenAPI v1 document](../api/rendezvous-v1.json) rather than hand-building HTTP.
## One caller-owned gameplay socket
Create the game's LiteNetLib manager through `RendezvousNetListener`; do not open
a separate NAT socket. The game owns start, stop, and disposal. A coordinator
owns polling while it is active, so call its `Poll()` once from the game/network
thread and do not also call `NetManager.PollEvents()` during that period.
```csharp
RendezvousNetListener networkEvents = new();
NetManager gameplayNetwork = networkEvents.CreateManager();
gameplayNetwork.ChannelsCount = 3; // set the game's required count before Start
if (!gameplayNetwork.Start(gameplayPort))
{
throw new InvalidOperationException("Gameplay UDP socket could not start.");
}
using RendezvousHostCoordinator host = new(
gameplayNetwork,
networkEvents,
mediatorEndPoint,
publishedSession,
joinClient);
host.Poll(); // call each game frame while this coordinator owns polling
```
LiteNetLib defaults to one QoS channel. Set `ChannelsCount` before `Start` when
the game protocol uses additional channels; both peers must configure the same
count. Rendezvous does not choose, remap, or reserve a gameplay channel.
Register normal game callbacks on `networkEvents.GameplayEvents`. Rendezvous
reserves only its authenticated direct requests and forwards other callbacks.
The same socket sends host presence, punches through the mediator, establishes
the peer, and then carries gameplay. A NAT introduction is not success; accept a
peer only after the coordinator reports the typed `Connected` outcome.
`Poll()` does not fetch new invitations. Schedule
`RefreshJoinAttemptsAsync` repeatedly for the entire hosting lifetime using a
bounded caller-owned timer (the diagnostic uses 250 ms), never allow two refreshes
to overlap, and inspect each typed result. The refresh performs HTTP work and
queues a snapshot; it does not call the LiteNetLib manager. Continue calling
`Poll()` on the manager's owning thread so the queued snapshot, presence traffic,
and callbacks are processed. Run the lease maintainer concurrently and cancel
both loops before disposing the coordinator.
For example, start one sequential refresh loop when hosting begins and await it
during shutdown:
```csharp
static async Task RefreshInvitationsAsync(
RendezvousHostCoordinator host,
CancellationToken cancellationToken)
{
using PeriodicTimer timer = new(TimeSpan.FromMilliseconds(250));
do
{
RendezvousClientResult<int> result =
await host.RefreshJoinAttemptsAsync(cancellationToken);
if (!result.IsSuccess)
{
ObserveBoundedHostRefreshFailure(result.Error);
}
}
while (await timer.WaitForNextTickAsync(cancellationToken));
}
```
On the joining side, create an attempt through `RendezvousJoinClient`, then give
the issued attempt to `RendezvousClientCoordinator` using the same manager and
listener. Cancellation, outcome reporting, bounded deadlines, fallback, and
lease-maintainer examples are in the packaged
[`FinalFactory.Rendezvous.Client` README](../../src/FinalFactory.Rendezvous.Client/README.md).
## Host admission remains game-owned
The coordinator privately validates and consumes the signed one-time connection
ticket before accepting the LiteNetLib transport request. Do not create a second
`ConnectionTicketValidator` beside it: the coordinator deliberately does not
expose the expected or presented ticket. A connected transport proves only that
Rendezvous authorized one attempt; it does not prove player identity,
entitlement, capacity, ban status, or gameplay compatibility.
Treat `AttemptCompleted` with a successful outcome and non-null `Peer` as the
start of game-owned admission. Keep that peer outside authoritative gameplay
until the game's normal authentication and admission exchange succeeds; disconnect
it on rejection or timeout:
```csharp
host.AttemptCompleted += (_, completed) =>
{
if (!completed.Outcome.IsSuccess || completed.Peer is null)
{
return;
}
BeginBoundedGameAuthentication(
completed.Peer,
onAccepted: AdmitToAuthoritativeGameplay,
onRejected: peer => peer.Disconnect());
};
```
Revoke an attempt when the game cancels it. Never log a ticket or capability. A
successful Rendezvous check must not bypass the game's authentication or
authoritative server rules. `ConnectionTicketValidator` is a lower-level
primitive for a custom transport integration that owns the complete request
acceptance path; it is not an extra gate for `RendezvousHostCoordinator`.
## Provision each game and environment
Provision game/environment scope before issuing credentials. The policy fixes
enabled regions, exact gameplay protocols, visibility and publisher trust modes,
metadata schema and byte budgets, quotas, and whether a dedicated fallback may
be published. Unknown or disabled scope fails closed. Follow
[game provisioning and signing-key lifecycle](../security/provisioning.md) for
the complete schema, principal kinds, secret providers, overlap, and revocation.
Dedicated publisher credentials belong only on trusted hosting infrastructure.
Never ship one in a player build, repository, image layer, appsettings file, URL,
argument, log, crash report, or analytics event. Issue a short-lived credential
scoped to one game/environment and its allowed regions from the trusted
deployment boundary. Player-host grants are issued to an authenticated player
session at runtime and are never embedded in the build. Player-host grants,
dedicated publishers, anonymous unlisted hosts, and operators are separate
principal kinds; do not interchange them.
Rotate signing keys with an overlap:
1. install a new authorized key inside its `NotBefore`/`SignUntil` window;
2. begin issuing with it while the old key remains verify-only;
3. wait at least the maximum credential lifetime plus allowed clock skew;
4. retire the old verifier after `VerifyUntil` and preserve custody records.
A suspected compromise is not routine rotation: stop issuance, revoke the exact
key through the protected operator route, remove or replace it in provisioning,
invalidate affected credentials, and follow the
[key-compromise runbook](../operations/incident-runbooks.md#signing-key-or-issuer-compromise).
## Metadata is public and policy-owned
Treat listing metadata as untrusted public input. Define a small allowlist in
each provisioned game's `MetadataValueMaxBytes`, set `RequiredMetadataKeys`, and
keep `MetadataMaxKeys` and `MetadataMaxBytes` to the smallest useful values. Values
must be display data only—for example a bounded map or ruleset identifier. Never
publish player identity, free-form chat, secrets, access tokens, internal
addresses, world state, or data needed for authoritative gameplay.
The platform contract caps metadata at 32 keys, 256 UTF-8 bytes per value, and
4096 encoded bytes total; tenant policy can and should be smaller. Build version
and display name are separately bounded public fields. Games must escape metadata
for their UI and must not infer trust from a listing being present.
## Local, staging, and production path
Use the checked-in Compose profile only for the local TestClient guide. For a
real environment:
1. provision the game/environment policy and externally held signing keys;
2. deploy one active service behind the source-preserving HTTPS/UDP topology in
[secure single-active Linux deployment](../deployment/linux.md);
3. install matching exact package versions in the game and set its service and
mediator endpoints through environment-specific configuration;
4. pass the TestClient health/publish/browse/punch/direct-traffic smoke using a
short-lived diagnostic credential;
5. run the topology harness and representative consumer-network trials; and
6. monitor typed outcomes and bounded metrics before broad rollout.
Rendezvous v1 has no relay, account system, matchmaking engine, server-browser
UI, gameplay authority, or durable session database. A game owns player-facing
recovery and an explicit fallback. Do not describe direct traversal as guaranteed.
+112
View File
@@ -0,0 +1,112 @@
# SpaceGame consumer pilot
Tracking: Rendezvous #21 and SpaceGame #3.
The current SpaceGame checkpoint proves that the v1 client boundary establishes
authenticated direct LiteNetLib traffic without taking ownership of the game's
protocol, admission, player identity, entity identity, capacity, lifecycle, or
gameplay payloads. Real Godot processes, reconnect, an explicit dedicated
fallback, and a fresh Linux export now pass. The public package restore and a
representative external NAT/CGNAT canary remain required before #21 can close.
## Pinned checkpoint
| Input | Value |
| --- | --- |
| Rendezvous compatibility source | `ebb5eb617c0bbb170418afab396b68584b7f992e` plus the current #21 configuration/evidence changes |
| Rendezvous package source | `07004cd75fe172aa5dfdb3edda22fc280a4c4477` |
| SpaceGame source | `f3f5bc29810c362656cd7143bec1ddc2cfaf9f22` |
| Client package | `FinalFactory.Rendezvous.Client` `1.0.0` |
| Contracts package | `FinalFactory.Rendezvous.Contracts` `1.0.0` |
| LiteNetLib | `2.1.4` |
| HTTP, UDP, ticket contracts | `1` |
| SpaceGame gameplay protocol | `2` |
At the checkpoint date, the Final Factory Gitea NuGet service was reachable but
both `FinalFactory.Rendezvous.*` `1.0.0` registrations returned HTTP 404. The run
therefore restored locally built candidate packages with the hashes recorded in
[`spacegame.json`](../evidence/consumers/spacegame.json). This proves candidate
compatibility, not immutable registry publication. The release package restore
must be repeated from the public feed.
## Proven local path
The SpaceGame host and client each create one caller-owned `NetManager`, set its
three gameplay QoS channels before `Start`, and give the same manager and
`RendezvousNetListener` to the coordinator. Rendezvous authenticates discovery,
join authorization, host presence, mediation, and connection outcome reporting.
After traversal, SpaceGame performs a separate audience-bound admission exchange
on its own reliable command channel. A trusted game-auth boundary mints the
opaque assertion; the player process never receives the signing key.
The authoritative host rejects expired, replayed, incorrectly signed,
wrong-listing, duplicate-player, over-capacity, identity-mismatched,
out-of-sequence, and over-rate traffic. It assigns a canonical game entity ID
only after admission. The player ID, entity ID, listing ID, join-attempt ID, and
LiteNetLib peer ID remain distinct values.
The bounded real-process harnesses observed:
- host publication and lease maintenance;
- browser compatibility filtering and join authorization;
- typed traversal outcome `Connected`;
- successful audience-bound game admission;
- reliable ordered frame-definition and spawn lifecycle records, reliable
ordered input, and sequenced state snapshots on the caller-owned gameplay
socket;
- disconnect and a new authenticated session for the same durable player while
LiteNetLib peers and canonical entity IDs change;
- immediate host lease renewal and successful host deregistration;
- a fresh optimized Linux export running the host and client in distinct
hardened container namespaces; and
- a forced punch timeout that connects the isolated client to an explicitly
advertised, non-loopback Docker-gateway fallback and repeats game admission.
Rendezvous exposes no gameplay relay API; all lifecycle, command, and snapshot
bytes are sent by SpaceGame through its caller-owned `NetManager`. Both Debug
and Release builds passed. Both Debug and Release test runs passed 31 tests with
zero failures. ExportRelease is optimized with debug symbols removed. The
focused formatter, shell checker, fresh-export provenance gate, clean
candidate-package restore, and adversarial branch review also passed.
## Failure evidence
| Path | Evidence | Status |
| --- | --- | --- |
| Incompatible protocol | protocol `999` returns no compatible listing and starts no traversal | Proven |
| Stale/no host presence | typed `NoHostPresence/RendezvousService/HostPresence/Mediation` | Proven |
| Traversal timeout | non-listening mediator produces typed `PunchTimedOut/LocalTraversal/NatTraversal/NatTraversal` | Proven |
| Rejected game admission | invalid signature denies gameplay in the process matrix; wrong audience, expiry, and replay are regression-tested | Proven |
| Capacity and duplicate player | game-owned roster rejects both and publishes current capacity | Regression-tested |
| Configured fallback | typed `PunchTimedOut`, explicit non-loopback endpoint, same game admission, direct gameplay | Proven locally and across Linux namespaces |
| Disconnect | host observes zero active players and final admitted count zero | Proven |
| Reconnect | same durable player enters a second authenticated session with new peer/entity IDs | Proven |
## Rendezvous-side compatibility fixes
The pilot found generic integration gaps and keeps their fixes in this
repository:
- the local production-shaped smoke tenant accepts SpaceGame gameplay protocol
`2` and the bounded `mode` metadata key;
- the Compose smoke tenant explicitly allows its private-network service name
and enables only the dedicated-endpoint fallback policy;
- SDK guidance requires games using multiple LiteNetLib QoS channels to set
`ChannelsCount` before `Start` and states that Rendezvous reserves no gameplay
channel; and
- the local credential helper rejects any signing-key file with group or other
permissions, in addition to its ownership, symlink, and hard-link checks.
Documentation contract tests cover these generic requirements.
## Remaining acceptance gates
Do not mark #21 passed until both remaining external gates have direct evidence:
1. restore the exact immutable `1.0.0` packages from the public Gitea feed; and
2. run representative external NAT/CGNAT canaries and record the network
topology and typed outcome.
SpaceGame #3 remains open independently for the production ENet replacement,
64/128-player profiles, and SIGTERM/drain/save evidence. The consumer pilot
does not claim those broader game-migration gates.
+235
View File
@@ -0,0 +1,235 @@
# Start-to-finish TestClient guide
Tracking: #20, #25
`FinalFactory.Rendezvous.TestClient` is the supported executable proof that a
consumer can publish, browse, authorize, punch, connect, exchange direct traffic,
and diagnose a failure using only the public Client and Contracts packages. It is
intentionally thin: a polished server browser and player-facing connection UI
belong in each game repository.
> **Traversal boundary:** Rendezvous v1 is not a relay and cannot guarantee a
> connection through symmetric NAT, carrier-grade NAT, restrictive firewalls,
> VPNs, or platform policy. It provides no accounts, social system, skill-based
> matchmaking, gameplay server, gameplay authority, or gameplay transport.
The automated scenario matrix, privileged Linux namespace run, and simulation
limits are in the [deterministic topology harness](topology-harness.md). The
[SDK seam guide](sdk-seams.md) covers the few integration details that this
executable cannot show.
## First local connection from a clean checkout
Prerequisites are the pinned .NET SDK, Docker with Compose, OpenSSL, Python 3,
`curl`, and `jq`. Run these commands from the repository root. The generated key
and credential are disposable local fixtures, not production provisioning.
```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
ready=false
for attempt in {1..45}; do
if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null; then
ready=true
break
fi
sleep 1
done
test "$ready" = true
curl --fail http://127.0.0.1:8080/health/live
curl --fail http://127.0.0.1:8080/health/ready
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
```
Only the host terminal needs a publisher credential. Disable shell tracing before
capturing it; the helper prints the credential on stdout so command substitution
can place it directly in the environment without writing it to disk.
```bash
set +x
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$(./scripts/mint-local-publisher-credential.sh)"
```
The helper accepts no arguments, reads the ignored `0600` local Compose key, and
mints only `space-game` / `smoke` / `local` / protocol `1` for ten minutes. It is
not a reusable issuer or an example for production. Never put the result in a
command argument, URL, shell history, log, screenshot, support ticket, captured
fixture, or source file.
In terminal 1, publish a host. It stays alive for at most 60 seconds and exits
after a joining peer completes the authenticated echo exchange:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
--configuration Release --no-build -- \
host --service http://127.0.0.1:8080/ --mediator 127.0.0.1:9050 \
--game space-game --environment smoke --region local --protocol 1 \
--display-name "Local diagnostic" --timeout-seconds 60 --run-seconds 60 \
--exit-after-echo
```
Copy the public listing ID printed by the host, or discover it from terminal 2:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
--configuration Release --no-build -- \
browse --service http://127.0.0.1:8080/ \
--game space-game --environment smoke --region local --protocol 1
```
In terminal 3, either omit `--listing` and select interactively, or provide the
copied ID for deterministic selection:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
--configuration Release --no-build -- \
join --service http://127.0.0.1:8080/ --mediator 127.0.0.1:9050 \
--game space-game --environment smoke --region local --protocol 1 \
--listing REPLACE_WITH_LISTING_UUID --timeout-seconds 30
```
Success means the joiner prints `join.connected` and verified direct traffic,
and the host prints verified direct traffic before deregistering. The host and
joiner each create one caller-owned LiteNetLib manager. The same UDP socket sends
presence and punch traffic, accepts the authenticated peer, and carries the
ping/echo/ack/completion payload; direct traffic does not pass through the HTTP
service or mediator.
Clean up secrets and the disposable service when finished:
```bash
unset RENDEZVOUS_PUBLISHER_CREDENTIAL
docker compose -f deploy/compose/compose.yaml down
rm deploy/compose/secrets/signing-key
```
## Script and JSON automation
`--script` forbids prompts and selects the first compatible listing unless
`--listing UUID` fixes the choice. `--json` emits one JSON object per line with
`version: 1`. New optional properties may be added, but event names and exit
codes are stable automation contracts. Informational events use stdout and
failures use stderr.
Successful direct-connection and direct-traffic events include the coarse
`addressFamily` value `ipv4` or `ipv6`. They never include the peer address.
The deployment smoke performs the full health, publish, join, mediation, direct
traffic, outcome-report, and cleanup flow using bounded waits:
```bash
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
./scripts/smoke-deployment.sh
```
For custom automation, capture JSON and preserve the process status separately:
```bash
set +e
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
--configuration Release --no-build -- \
browse --service http://127.0.0.1:8080/ \
--game space-game --environment smoke --region local --protocol 1 \
--script --json >browse.jsonl
status=$?
set -e
jq -e 'select(.version == 1 and .event == "browse.completed")' browse.jsonl
test "$status" -eq 0
```
Never use an unbounded sleep to orchestrate processes. Wait for versioned events
such as `host.ready` and apply a deadline. Useful success events are
`host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`,
`browse.completed`, `browse.session`, `join.connected`, `join.direct-traffic`,
`join.outcome-report`, `watch.snapshot`, `watch.session-upsert`,
`watch.session-remove`, `watch.reset`, `watch.reconnect`, and `watch.complete`.
For a bounded live-directory diagnostic, use `watch --run-seconds 60`. Add
`--exercise-reset --script` to prove fail-closed cursor recovery, or
`--exercise-reconnect --script` while changing one listing to prove ordered
`Last-Event-ID` replay after a deliberate disconnect. The full event and proxy
contract is in [live session-list updates](live-session-updates.md).
| Exit | Meaning |
| ---: | --- |
| `0` | Requested diagnostic flow completed successfully |
| `2` | Invalid command or options |
| `3` | Missing or invalid local configuration |
| `10` | HTTP, registration, browser, lease, or socket failure |
| `11` | No compatible session was available or selected |
| `12` | Authorization or traversal reached a typed terminal failure |
| `13` | Direct connection succeeded but the direct traffic proof failed |
| `130` | Caller cancellation or Ctrl+C |
## Observe a safe failure
Run this after the protocol-1 browse in terminal 2 and before the terminal-3
join (or restart terminal 1 first). The preceding browse proves that one
protocol-1 host is present. Now browse for deliberately incompatible protocol
`999`. The command emits a successful directory response with
`browse.completed`, `count: 0`, then exits `11` to distinguish compatibility
from a service outage:
```bash
set +e
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
--configuration Release --no-build -- \
browse --service http://127.0.0.1:8080/ \
--game space-game --environment smoke --region local --protocol 999 \
--script --json >incompatible.jsonl
status=$?
set -e
jq -e 'select(.event == "browse.completed" and .phase == "directory" and .count == 0)' \
incompatible.jsonl
test "$status" -eq 11
```
This is a diagnostic failure drill, not a bypass: unknown tenant scope and
protocols still fail closed, and the local helper cannot mint a credential for
them.
## Diagnose by phase, not by guesswork
Start with the exit code, then the last versioned event and its `phase`, `status`,
and typed `outcome`. Endpoint categories may be
reported as `loopback`, `private`, or `public`; raw endpoints, credentials,
capabilities, metadata, and player identities are never emitted.
| Symptom or last event | Distinction | Check next |
| --- | --- | --- |
| `host.configuration`, exit `3` | Local credential variable is missing or malformed before any request | Confirm the named environment variable exists, tracing is off, and the credential has not expired |
| `host.registration`, exit `10` | Publisher authentication, tenant policy, metadata, quota, or HTTP failure | Use the typed status; compare credential scope with game/environment/region and the provisioned policy, then correlate protected server telemetry by operation and time |
| `browse.sessions`, exit `10` | Directory request failed | Check HTTP reachability, `/health/ready`, rate limiting, and contract compatibility |
| `browse.completed` count `0`, or `join.selection` empty, exit `11` | Healthy directory but no compatible visible listing | Match game, environment, region, and exact gameplay protocol; then confirm a host lease is still active |
| Exact `join.selection` failure, exit `10` | Listing disappeared, is hidden, or scope no longer matches | Browse again; do not retry an old listing ID forever |
| `join.authorization`, exit `12` | Service rejected the attempt before NAT traversal | Inspect typed category/outcome for policy, capacity, stale host, or active-attempt limits |
| `join.punch` / `join.traversal`, exit `12` | Mediation or NAT traversal did not establish a peer | Confirm UDP endpoint/reply path, host presence, clocks, firewall/NAT behavior, and topology; use a game-owned fallback if policy supplies one |
| `join.direct-connect`, exit `12` | Introduction occurred but authenticated direct admission failed | Confirm host is polling the same socket, the one-time ticket is current, and game admission did not reject capacity, identity, or bans |
| `join.connected` followed by exit `13` | Peer connected but the direct gameplay-like echo did not finish | Inspect the peer lifecycle and caller polling; this is not an HTTP/directory failure |
Stopping a host without deregistration may leave its listing visible only until
the bounded lease expires. During that window, a join can produce a typed stale
host or traversal outcome; it must not be interpreted as a healthy host. Restarting
the single-active service intentionally loses all ephemeral listings and attempts,
so hosts re-register and clients browse again.
If a terminal outcome reports an authoritative dedicated fallback,
`join.fallback` exposes only availability and endpoint type. TestClient never
connects to it automatically. The game owns the decision, authentication, and
connection policy. If no fallback is present, Rendezvous v1 offers no relay.
## Production use
Do not copy a production signing key to a diagnostic host. Supply a short-lived,
least-scope publisher credential from the deployment secret boundary and set the
external service, mediator, and matching scope variables described in the
[secure Linux deployment smoke](../deployment/linux.md#http-and-udp-smoke).
Run representative external-network tests; loopback success is not NAT coverage.
Use the redacting, bounded
[real-network canary procedure](../operations/production-readiness.md) for formal
production evidence rather than committing raw TestClient JSON.
+91
View File
@@ -0,0 +1,91 @@
# Deterministic topology harness
Issue #14 is verified at three layers. The layers are deliberately separate so
the always-on gate remains deterministic while privileged CI workers can add a
stronger operating-system topology without overstating what local emulation
proves about the public Internet.
## Always-on public-process gate
`TestClientProcessIntegrationTests` launches the built server and the same
`FinalFactory.Rendezvous.TestClient` executable shipped to operators. Every
child process uses `--script --json`, dynamic HTTP and UDP ports, bounded
state-driven waits, and enforced process-tree cleanup.
The suite proves:
| Scenario | Required observation |
| --- | --- |
| Three-party happy path | register, presence-ready, browse, authorize, punch, authenticated LiteNetLib connection, direct ping/echo/ack/completion traffic, outcome report, disconnect, deregister |
| Same-LAN candidate | the connected peer is reported as `loopback` or `private`, never inferred merely from an introduction callback |
| Empty and missing selection | browse exits `11`; exact missing lookup exits `10` |
| Wrong tenant/protocol | no listing is returned for an incompatible protocol; exact joins with either mismatch fail before `join.punch` |
| Traversal timeout | an unreachable mediator produces typed `PunchTimedOut`, exits `12`, advertises the configured dedicated fallback, and never connects to it |
| Caller cancellation | POSIX `SIGINT` exits `130`, deregisters the listing, and removes it from public lookup |
| Abrupt host loss | the listing disappears after the presence window and before its lease expires; public exact lookup intentionally reports `NotFound` |
| Bounded host without a peer | exits `13` and still deregisters |
Captured output is parsed as the stable JSON v1 event schema. Publisher
credentials and signing-key material are checked against all captured output.
The direct traffic payload is handled only by the caller-owned host and client
LiteNetLib managers; the HTTP service and mediator do not implement or observe
the echo protocol.
Run the always-on scenarios with:
```bash
dotnet test Rendezvous.slnx --configuration Release --no-build \
--filter FullyQualifiedName~TestClientProcessIntegrationTests
```
## Deterministic protocol and adverse-state gate
The following real service-boundary tests cover conditions that a public CLI
cannot safely manufacture by accepting raw capabilities or tickets:
| Scenario | Test evidence |
| --- | --- |
| Same-NAT private candidates | `NatMediationProcessorTests.MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates` |
| Separate observed endpoints | `NatMediationProcessorTests.DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints` |
| One-time introduction and replay | `InMemoryEphemeralRendezvousStoreTests.AttemptCapabilitiesAndIntroductionAreOneTime` |
| Direct ticket replay | `RendezvousCoordinatorIntegrationTests.CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay` |
| Wrong tenant/protocol and stale presence | `InMemoryEphemeralRendezvousStoreTests.JoinRequiresExactScopeProtocolAndFreshHostPresence` |
| Cancellation and late callbacks | `RendezvousCoordinatorBehaviorTests.CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt` |
| Mediator restart | both cases of `UdpMediatorServiceTests.NativeLiteNetLibRequestsIntroduceTheAuthorizedPair`; the restarted case rebinds the same UDP port and completes a native LiteNetLib introduction |
These tests use fake monotonic clocks or state predicates where expiry and race
ordering matter. They do not use fixed sleeps as proof of state.
## Privileged Linux namespace gate
When a Linux CI worker can create network namespaces, the workflow sets
`RENDEZVOUS_RUN_NETNS_TESTS=1` and reruns
`PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints`.
The test creates a temporary WAN bridge, an isolated service namespace, two NAT
router namespaces, and isolated host/client LAN namespaces. Each NAT has its own
inside subnet and WAN address. Linux forwarding plus per-router MASQUERADE rules
force the service to observe separate translated endpoints; the public TestClient
processes must then complete authenticated direct traffic through those mappings
using the public candidate. Namespaces, rules, veth pairs, bridge, processes, and
sockets are removed in bounded async-disposal paths. A cleanup failure fails the
test.
If `ip netns add`/`iptables` is unavailable or the worker lacks `CAP_NET_ADMIN`,
CI records the limitation and keeps the always-on loopback suite as the required gate.
To request the privileged run explicitly:
```bash
RENDEZVOUS_RUN_NETNS_TESTS=1 dotnet test Rendezvous.slnx \
--configuration Release --no-build \
--filter FullyQualifiedName~PrivilegedLinuxNatNamespacesCompleteDirectTrafficAcrossSeparateObservedEndpoints
```
## What this does not prove
Loopback, MASQUERADE, and namespace routing cannot reproduce every consumer router,
carrier-grade NAT, firewall, IPv6 transition mechanism, symmetric NAT mapping,
or real-world packet-loss pattern. The separate-observed-endpoint processor
test proves that untrusted private claims are excluded and public candidates are
selected; it is not presented as universal Internet traversal proof. Real
network canaries and measured production readiness remain the scope of issue
#23.
+99
View File
@@ -0,0 +1,99 @@
# Unscouted consumer pilot
Tracking: Rendezvous #22 and Unscouted #459.
The current checkpoint independently proves that the v1 contracts are not
shaped only around SpaceGame. A real Godot Unscouted host and clients consume
the same Client and Contracts package surface, use one caller-owned LiteNetLib
socket for NAT callbacks and gameplay, perform Unscouted's own keypair
authentication and host admission, exchange gameplay, and exercise a
game-owned fallback. The public package restore and representative external
NAT/CGNAT canary remain required before #22 can close.
## Pinned checkpoint
| Input | Value |
| --- | --- |
| Rendezvous configuration source | `f368fec6eb4344a6042974f58f888cf0f1ac8e8e` |
| Rendezvous package source | `07004cd75fe172aa5dfdb3edda22fc280a4c4477` |
| Unscouted implementation | `1e5886aa7f1e44689b4c75e32693eb7b19fd72d7` |
| Unscouted evidence | `f0574a7de82aadff6495ca5657dfc19cf7c2f67c` |
| Client package | `FinalFactory.Rendezvous.Client` `1.0.0` |
| Contracts package | `FinalFactory.Rendezvous.Contracts` `1.0.0` |
| LiteNetLib | `2.1.4` |
| Godot | `4.7.stable.mono.arch_linux.5b4e0cb0f` |
| Game / environment / region | `unscouted` / `smoke` / `local` |
| Rendezvous and gameplay protocol | `1` |
The exact package hashes are recorded in
[`unscouted.json`](../evidence/consumers/unscouted.json). A clean restore into an
empty package directory using only the consumer's checked-in `NuGet.config`
returns `NU1101` for both packages. The verified local run used those exact
candidate package files from the existing cache. This proves compatibility,
not immutable registry publication.
## Game-neutral service boundary
Rendezvous #22 adds provisioning data, not an Unscouted branch in the server or
SDK. The local production-shaped tenant permits protocol `1`, region `local`,
public managed-dedicated listings, and the three bounded presentation keys
`mode`, `world`, and `mods`. The short-lived credential helper accepts only the
explicitly provisioned `space-game` and `unscouted` scopes and selects a
distinct game-scoped signing-key ID and subject.
The consumer rejects any metadata key outside its three-key presentation
schema and neutralizes control/BBCode characters before display. Rendezvous
never receives Unscouted player keys or resolved identities, colony authority,
simulation or persistence state, fog/interest state, or gameplay packets.
## Proven real Godot path
The normal `NetLaunch` argument path recognizes `--rendezvous-pilot` and opens a
dedicated scene. That scene uses Unscouted's real `LiteNetLibTransport`,
`GameServer`, `GameClient`, `ServerAuthenticator`, and `ClientAuthenticator`.
It is not a copied SDK adapter.
One bounded run against the hardened Compose service started a host plus:
- a protocol-`999` client that found no compatible listing;
- a direct client that received an authorized introduction, completed
same-socket traversal, passed Unscouted keypair admission, and exchanged an
Unscouted gameplay ping/pong; and
- a client pointed at a non-listening mediator that received a typed traversal
failure, applied the fallback decision in Unscouted code, repeated admission,
and exchanged the same gameplay ping/pong through the ordinary game
transport.
The direct client also proved that both a `space-game` join request and a
`production` environment join request return exact `NotFound` results for the
Unscouted listing. The host renewed its lease, admitted two independently
authenticated sessions, completed two gameplay exchanges, and deregistered the
listing on shutdown.
## Verification
- Rendezvous Debug and Release: 299 tests passed in each configuration, zero
failures.
- Unscouted Debug and Release: non-incremental builds passed; 3,310 tests passed
with 15 intentional skips in each configuration.
- Unscouted gdUnit/Godot: 360 tests passed, zero skipped or failed. The harness
fix in Unscouted #461 keeps compilation headless and leaves the open editor's
build tree unchanged.
- The final Godot pilot, ShellCheck, JSON/whitespace checks, formatting gate,
and adversarial branch review passed.
- Export is not applicable because the Unscouted checkout has no
`export_presets.cfg`; both C# configurations and the actual Godot entry point
were exercised.
## Remaining acceptance gates
Do not mark #22 passed until both external gates have direct evidence:
1. publish or expose the exact immutable `1.0.0` packages on the configured
Gitea feed and repeat the empty-cache consumer restore; and
2. run the same Godot host/client path across representative residential,
CGNAT, and IPv6/multi-host networks, recording the topology and typed
direct/fallback outcome.
The loopback run proves the real process, socket, authentication, and gameplay
shape. It does not claim production Internet traversal coverage.
+189
View File
@@ -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 169 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.
+339
View File
@@ -0,0 +1,339 @@
# Incident and change runbooks
Tracking: #20
These runbooks supplement the [signal and operator reference](observability-and-operator-runbook.md).
Every procedure has four explicit gates: detect, contain, recover, and verify.
Record timestamps, the release digest, bounded aggregates, audit fingerprints,
and `X-Rendezvous-Correlation-ID` values. Never copy credentials, capabilities,
connection tickets, signing material, player identity, raw IP addresses,
endpoints, listing metadata, or full request bodies into an incident record.
Operator routes must be reachable only from an allowed management source. Use a
short-lived, least-permission operator credential minted outside Rendezvous.
Pass it to an approved operator client through protected stdin or a secret agent,
not a URL, command argument, environment-wide process launcher, shell trace, or
ticket. All request shapes and responses are defined by the generated
[OpenAPI v1 document](../api/rendezvous-v1.json).
Before an incident, keep these protected records available without depending on
the affected service: current and previous image digests, matching configuration,
key IDs and lifecycle windows (not raw key values), the game owner/on-call map,
capacity baselines, collector destinations, and a separately authorized
break-glass operator key. Test management-source allowlisting and credential
permissions at least once per release.
Use the exact versioned action shapes below. Confirmation fields deliberately
repeat the target so a stale UI selection or copy error fails closed. Responses
do not echo targets.
| Operation | JSON body |
| --- | --- |
| `POST /v1/operator/listings/revoke` | `{"listingId":"<uuid>","confirmListingId":"<same uuid>"}` |
| `POST /v1/operator/principals/revoke` | `{"subject":"<exact subject>","confirmSubject":"<same subject>","lifetimeSeconds":60}` |
| `POST /v1/operator/keys/revoke` | `{"keyId":"<key id>","confirmKeyId":"<same key id>"}` |
| `POST /v1/operator/drain` | `{"confirmation":"DRAIN"}` |
## Abuse or authentication spike
### Detect
- Alert on a baseline-relative increase in `rendezvous.limiter.drops`, HTTP/UDP
request rate, `rendezvous.operator.authentication` rejected/forbidden results,
registration requests by authentication status, queue depth, or p95/p99 latency.
- Check `/health/live`, `/health/ready`, `rendezvous.store.available`, and
authenticated `GET /v1/operator/status`. Separate public-source rejection,
publisher credential failure, operator probing, and ordinary capacity growth.
- Use only bounded operation/result dimensions and correlation IDs. Do not group
by raw address, token, subject, listing ID, or metadata.
### Contain
- Preserve the dedicated operator partition. Do not raise public limits during
an active spike. Apply source-preserving edge rate controls only when their
collateral effect is understood and UDP source address/port remains intact.
- For one abusive session, call `POST /v1/operator/listings/revoke` with identical
`listingId` and `confirmListingId`. For a confirmed publisher subject, call
`POST /v1/operator/principals/revoke` with identical `subject` and
`confirmSubject` and a 1600 second lifetime.
- Revoke a signing key only when compromise evidence implicates that issuer;
broad key revocation invalidates every credential signed by it. Drain only if
the process itself must be isolated.
### Recover
- Correct the source integration, edge rule, leaked principal grant, or tenant
budget under change control. Let a bounded principal revocation expire only
after the owner confirms remediation; a repeated shorter revocation never
shortens the original deadline.
- Restore normal limits gradually. If saturation caused state churn, allow leases
and attempts to expire naturally rather than deleting arbitrary state.
### Verify
- Require limiter drops, authentication result ratios, queue depth, latency, and
direct-connect outcomes to return to the same-region baseline for the agreed
observation window.
- Confirm readiness stayed healthy or recovered, operator audit contains the
intended action/result fingerprint, revoked resources cannot create new work,
and unaffected tenants can still publish, browse, and connect.
## Signing key or issuer compromise
### Detect
- Treat secret-manager access alerts, unexpected issuance, credentials outside
the expected region/kind, a signing-key expiry alarm, or unexplained publisher
authentication growth as compromise until disproved.
- Identify the non-secret key ID, allowed credential kinds, game/environment
binding, `NotBefore`, `SignUntil`, and `VerifyUntil`. Do not retrieve or paste
raw material merely to compare it.
### Contain
- Stop the affected external issuer and deny further access to its secret.
- From a separate uncompromised break-glass operator key with `RotateKeys`, call
`POST /v1/operator/keys/revoke` with identical `keyId` and `confirmKeyId`.
Runtime revocation is immediate but process-local.
- Remove or mark the key revoked in authoritative provisioning before any
restart. Revoke affected principals/listings when narrower evidence supports
it. Do not drain automatically unless the running instance cannot be trusted.
### Recover
- Generate replacement material in the approved secret boundary, use a new key
ID, bind it to the exact credential kind and tenant, and deploy configuration
referencing the secret—never the secret value.
- Resume issuance with short lifetimes. Reissue only to authenticated workloads.
When confidentiality is lost, do not use normal overlap to keep compromised
credentials valid; document the intentional invalidation window.
- Rotate any release, registry, or operator credential exposed by the same
incident through its owning system; Rendezvous key revocation cannot revoke
unrelated systems.
### Verify
- Confirm `GET /v1/operator/status` shows the compromised key revoked and the
replacement signing, old credentials fail, new exact-scope credentials work,
and the result survives a controlled restart from updated provisioning.
- Pass TestClient registration, browse, authenticated mediation, and direct
traffic with the replacement; monitor authentication and audit results through
at least the maximum newly issued credential lifetime.
## Targeted listing or publisher revocation
### Detect
- Validate the abuse report against game-owned records and bounded Rendezvous
evidence. Determine whether the target is one listing or an authenticated
publisher subject. Do not use display name, metadata, or a raw address as
identity.
- Confirm current aggregate state through `GET /v1/operator/status` and record
the correlation IDs that justified action.
### Contain
- Revoke one listing with `POST /v1/operator/listings/revoke`; the exact listing
UUID must appear in both confirmation fields.
- Revoke a publisher with `POST /v1/operator/principals/revoke`; the exact subject
must appear in both confirmation fields and `lifetimeSeconds` must be 1600.
This removes that principal's active listings and attempts and blocks new ones
for the bounded lifetime.
- Choose the narrowest action. Do not revoke a tenant key for a single listing.
### Recover
- The game owner resolves the ban, account, workload, or configuration issue in
the authoritative game system. Rendezvous does not own user accounts or bans.
- After the original revocation deadline, permit a newly authenticated publisher
to register. There is no un-revoke endpoint and no recovery of removed
ephemeral listings; the host creates a new listing.
### Verify
- Confirm the old listing is no longer browsable or joinable, the principal
cannot publish during its lifetime, and the audit action/result is present
without the raw target.
- Confirm unrelated publishers in the same tenant and another tenant still pass
publish/browse/join/direct-traffic checks.
## Planned restart or crash recovery
### Detect
- Planned restart begins with a recorded change and a healthy current baseline.
Crash recovery begins when liveness/process state fails or both TCP 8080 and
UDP 9050 stop answering. Distinguish dependency/readiness failure from a dead
process; liveness deliberately remains healthy for some recoverable failures.
- Record active listing/lease/attempt aggregates. They are informational only:
v1 has no durable runtime database to restore.
### Contain
- For a planned stop, call `POST /v1/operator/drain` with confirmation exactly
`DRAIN`. Require readiness `503`, liveness `200`, and removal from new traffic.
Allow the bounded drain deadline to finish, then send SIGTERM.
- Never start a second active instance while the old process owns the advertised
HTTP/UDP endpoints. On crash, fence the old process/host and verify both sockets
are released before replacement.
### Recover
- Start exactly one instance from the recorded immutable image digest and matching
reviewed configuration/key references. A restart intentionally loses listings,
observed endpoints, attempts, replay markers, and runtime-only revocations.
- Ensure any emergency key revocation is also present in authoritative
provisioning. Hosts must re-register; clients must browse and start new
attempts. Do not restore stale ephemeral state from logs or backups.
### Verify
- Require live and ready health, UDP bind, store availability, and one active
target. Run the full deployment smoke and confirm host re-registration begins.
- Verify no pre-restart listing or capability is accepted, runtime revocations
that should persist are configuration-backed, and latency/outcomes stabilize.
## Release rollback
### Detect
- Trigger rollback from a predeclared objective: readiness loss, failed deployment
smoke, contract/package incompatibility, security regression, direct-success
regression beyond threshold, or sustained resource regression. Record the new
and previous digests and the evidence; do not move a tag.
### Contain
- Stop promotion and new rollout work. Drain and stop the faulty single active
instance, then verify both public sockets are released. Revoke affected keys or
principals only when the defect creates an authorization risk.
- Preserve logs, artifacts, provenance, signatures, and the faulty release record.
Never overwrite or delete an immutable package/image to reuse its version.
### Recover
- Deploy the previous known-good image by digest with its compatible configuration
and key set. Do not run old and new concurrently. If configuration changed,
apply its reviewed down-migration before starting.
- Publish a corrected build under a new SemVer after diagnosis; mark faulty release
notes withdrawn when appropriate.
### Verify
- Check the running image digest, live/ready health, one active target, UDP source
preservation, and the complete TestClient deployment smoke.
- Confirm package/server compatibility from `GET /v1/operator/status`, hosts
re-register, and the rollback objective returns to baseline for the observation
window.
## Capacity saturation
### Detect
- Page when `rendezvous.queue.depth` remains above 90% of the configured attempt
limit, lease-critical work is shed, `rendezvous.store.available` is zero, or no
ready instance remains. Warn at 70%, sustained `rendezvous.limiter.drops`, or
p95 latency above objective.
- Compare CPU, memory, file descriptors, UDP errors, expiry churn, HTTP operation
rate, and typed connection outcomes with the measured
[capacity profile](capacity-and-resilience.md). Distinguish legitimate growth,
attack traffic, downstream telemetry pressure, and a regression.
### Contain
- Preserve lease-critical and operator reserves. Shed new browse/join work with
the existing typed `429`/`Retry-After` behavior; do not add an unbounded queue.
- Apply per-tenant/source controls at the appropriate trusted boundary. If the
process is unstable, drain new work and recover on one replacement rather than
adding a second active replica; v1 state is process-local.
### Recover
- Remove the causal load or deploy a tested higher single-instance resource and
budget profile. Change CPU/memory and server limits together, using the numeric
gate and accelerated soak before production.
- Long-term horizontal scaling requires a designed shared directory, replay, and
attempt authority. A generic load balancer is not that design.
### Verify
- Re-run the capacity/resilience gate at the chosen profile, then require queue,
limiter drops, expiry churn, latency, store health, and direct-success ratio to
remain within objectives through the production observation window.
- Confirm termination still completes within `DrainDeadlineSeconds + 5` and the
public and operator partitions behave independently.
## Privacy or telemetry incident
### Detect
- Trigger on any credential, token, capability, player identity, raw IP/endpoint,
listing ID, metadata, or caller-reported exact connection duration tied to an
event or identity found in logs, metrics, traces, crash reports, support systems,
or analytics. Aggregate HTTP/UDP duration histograms with bounded operation tags
are expected telemetry. Also trigger when audit data exceeds its approved 30-day
retention without an incident hold.
- Identify the producing version, sink, access population, retention/replication
path, and time window without copying the exposed value into a new system.
### Contain
- Stop or filter the offending export and restrict access to affected sinks.
Preserve the minimum evidence under the incident process; do not take broad
diagnostic dumps that amplify exposure.
- Revoke exposed reusable credentials/keys through their owning boundary. Listing
IDs and endpoints are not authentication secrets, but remove affected listings
if continued exposure creates risk. Notify privacy/security owners according to
applicable policy and law.
### Recover
- Patch the producer to the allowlisted telemetry model, test canary redaction
across logs/metrics/traces/output, and deploy through the immutable release
path. Delete or age out affected data from every sink according to approved
retention and legal-hold direction.
- Replace exposed credentials and re-register hosts when necessary. Do not claim
that a service restart deletes copies already exported to collectors.
### Verify
- Search new telemetry using non-secret synthetic canaries and confirm no canary
or prohibited field crosses the boundary. Verify audit records contain only
fixed fields and fingerprints and that retention/eviction is operating.
- Security/privacy owners confirm sink cleanup, access review, notification, and
monitoring closure before the incident is resolved.
## Dependency or base-image upgrade
### Detect
- Open a reviewed change for an advisory, end-of-support date, pinned-digest
refresh, or planned package update. Record affected package/image, current and
proposed exact version/digest, advisory severity, exploitability, and required
deadline. Never float to `latest` as remediation.
### Contain
- For an actively exploited critical issue, restrict exposure or stop the service
under incident authority while building the fix. Revoking publisher keys does
not repair a vulnerable runtime. Otherwise keep the known-good release running
while the candidate is tested.
### Recover
- Update the SDK/base-image digest, lock files, license/advisory evidence, SBOM,
compatibility matrix, and release notes together. For LiteNetLib or a wire/API
change, apply the explicit version/migration policy rather than silently
replacing compatible bytes.
- Run locked restore, formatting, Debug and Release builds/tests, public contract
and package gates, real consumer restores, reproducible artifact/image builds,
vulnerability scan, signatures, topology/deployment smoke, and capacity checks
proportional to the change. Promote the exact tested digest.
### Verify
- Verify signatures, provenance, checksums, SBOM contents, running digest, and
absence of the advisory in the shipped artifact—not merely the build host.
- Require live/ready health, TestClient direct traffic, real consumer compatibility,
and normal latency/outcomes. Keep the previous digest and compatible config for
rollback until the observation window closes.
@@ -0,0 +1,148 @@
# Observability and operator reference
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.
Concrete detect/contain/recover/verify procedures for abuse, key compromise,
targeted revocation, restart, rollback, saturation, privacy incidents, and
dependency upgrades are in the [incident and change runbooks](incident-runbooks.md).
## 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.
+217
View File
@@ -0,0 +1,217 @@
# Production-readiness decision and real-network canary
Tracking: #23
Rendezvous v1 is **not production-ready** until every required gate in
[`production-readiness-v1.json`](../evidence/production-readiness-v1.json) is
recorded as `pass`. The machine-checkable decision is intentionally fail-closed:
```bash
./scripts/check-production-readiness.sh
```
Exit `0` means every required gate is present and passing, exit `3` means the
record is valid but at least one gate is pending or failed, and exit `2` means
the record itself is malformed or contains identifier-, endpoint-, account-, or
credential-shaped data. Editing only the top-level decision cannot make the
check pass.
The checked-in record is an index, not a log archive. It contains one
repository-relative evidence reference and a short categorical note per gate.
Raw packet captures, client event streams, publisher credentials, public or
private network endpoints, listing IDs, and player/account identifiers must not
be committed.
## Required decision matrix
The local matrix covers immutable artifacts, Debug and Release verification,
both real game consumers, the candidate capacity/resilience profile,
production-process recovery, and the combined security/privacy/observability
gate. These may be reproduced by the project team on a clean candidate commit.
The external matrix remains distinct because a local namespace, loopback,
container bridge, or second process on one machine cannot prove it:
| Gate | Required evidence |
| --- | --- |
| Public package empty-cache restore | A clean machine restores the exact Client and Contracts version using only the documented public sources. |
| Signed publication | The immutable tag publishes packages, image digest, SBOMs, provenance, checksums, and verifiable signatures through the protected release workflow. |
| Source-preserving UDP ingress | Packet capture on the service host proves the mediator observes each peer's real source tuple and replies from the advertised public tuple; no UDP proxy rewrites either direction. |
| Same-LAN direct canary | Two independently operated game clients establish authenticated direct LiteNetLib traffic. |
| Home-NAT direct canary | Host and joiner on distinct residential networks establish authenticated direct LiteNetLib traffic. |
| Restrictive/CGNAT and blocked-UDP canaries | Each bounded join exits `12`, records a typed terminal category, and exposes the game-owned fallback policy without hanging or claiming success. |
| IPv6 direct canary | Two external IPv6 clients record authenticated direct traffic and an observed `ipv6` peer address family. |
| Public rate-shaped capacity | The documented HTTP/UDP workload mix meets its objectives through TLS, Kestrel, JSON, LiteNetLib, kernel sockets, and public ingress. |
| One-hour endurance | The immutable production-shaped candidate completes the one-hour profile without a state, handle, memory, readiness, or latency failure. |
| Alert delivery | A real alert sink receives both trigger and recovery notifications for the rehearsed outage. |
| Cold-standby rollback | Drain, stop, socket release, replacement start, host re-registration, and rollback meet the process and host-visible recovery objectives. |
| Documentation-only exercise | An operator who did not author the runbooks completes key rotation/revocation, outage, restart, re-registration, and rollback using only the checked-in documentation. |
Failure or missing evidence is blocking. It is never converted into an accepted
risk by changing the wording of the readiness note.
When an external gate passes, add a redacted repository JSON attestation and
point that gate's `evidenceRef` to it. The checker requires this exact shape and
binds the gate to the evaluated candidate commit. `artifactDigest` is the SHA-256
of the protected evidence bundle or public release record, not a peer endpoint,
listing identifier, account identifier, or credential:
```json
{
"schemaVersion": 1,
"kind": "rendezvous-external-gate-attestation",
"gateId": "replace-with-the-exact-gate-id",
"candidateCommit": "replace-with-the-40-character-candidate-commit",
"result": "pass",
"performedAtUtc": "2026-01-01T00:00:00Z",
"artifactDigest": "replace-with-the-64-character-sha256",
"evidenceLocation": "protected-operations-record",
"reviewerRole": "independent-operator"
}
```
Allowed evidence locations are `protected-operations-record` and
`public-release-record`. Allowed reviewer roles are `release-operator`,
`network-operator`, `security-operator`, and `independent-operator`. The checker
rejects a missing file, wrong gate, wrong candidate, malformed digest, naive
timestamp, extra fields, or sensitive-data-shaped contents.
## Prepare one immutable canary build
Use the exact release candidate on every canary machine. Verify a clean checkout,
restore in locked mode, and build the TestClient before changing networks:
```bash
test -z "$(git status --porcelain)"
dotnet restore Rendezvous.slnx --locked-mode
dotnet build Rendezvous.slnx --configuration Release --no-restore
```
Keep shell tracing disabled. The host receives a short-lived, least-scope
publisher credential through `RENDEZVOUS_PUBLISHER_CREDENTIAL`; it must never be
put in an argument, coordination file, evidence file, command transcript, or
support message. Set the public HTTPS service URL and advertised UDP mediator
tuple separately. TestClient rejects credentials embedded in the service URL.
## Run a success canary across two machines
On the host machine, choose `same-lan`, `home-nat`, or `ipv6-direct`. The
coordination file is mode `0600` and contains only the temporary listing UUID.
It is not evidence; transfer it through an approved private channel, then delete
both copies.
```bash
set +x
export RENDEZVOUS_PUBLISHER_CREDENTIAL='supplied-by-the-approved-secret-boundary'
export RENDEZVOUS_CANARY_ROLE=host
export RENDEZVOUS_CANARY_TOPOLOGY=home-nat
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
export RENDEZVOUS_CANARY_HTTP_URL='https://service.example.invalid/'
export RENDEZVOUS_CANARY_UDP_ENDPOINT='203.0.113.10:9050'
export RENDEZVOUS_CANARY_COORDINATION_FILE="$HOME/.local/state/rendezvous-canary-listing"
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/home-nat-host.json"
./scripts/run-real-network-canary.sh
```
The host prints only that it is ready and waits for the authenticated exchange.
On the joiner, read the securely transferred UUID without placing it in shell
history and run the matching topology:
```bash
set +x
read -r RENDEZVOUS_CANARY_LISTING_ID < "$HOME/.local/state/rendezvous-canary-listing"
export RENDEZVOUS_CANARY_LISTING_ID
export RENDEZVOUS_CANARY_ROLE=client-success
export RENDEZVOUS_CANARY_TOPOLOGY=home-nat
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
export RENDEZVOUS_CANARY_HTTP_URL='https://service.example.invalid/'
export RENDEZVOUS_CANARY_UDP_ENDPOINT='203.0.113.10:9050'
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/home-nat-client.json"
./scripts/run-real-network-canary.sh
unset RENDEZVOUS_CANARY_LISTING_ID
```
The host summary requires authenticated direct traffic and deregistration. The
client summary requires connection, authenticated direct traffic, accepted
outcome reporting, and the declared address family observed on the actual peer.
The summaries deliberately contain no network tuple or listing identifier.
For IPv6, set the topology to `ipv6-direct`, the family to `ipv6`, and use the
deployment's bracketed IPv6 mediator form. Record unsupported operating systems,
console platforms, VPNs, and address families as untested; an IPv4 pass is not
evidence for IPv6 or a platform network policy.
## Run a bounded failure canary
Start the host from an independently reachable network as above. On the joiner,
apply the reviewed firewall rule that blocks the relevant UDP path, or use the
known restrictive carrier network, then set `client-expected-failure` and the
matching topology:
```bash
export RENDEZVOUS_CANARY_ROLE=client-expected-failure
export RENDEZVOUS_CANARY_TOPOLOGY=firewall-blocked-udp
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/firewall-blocked-client.json"
./scripts/run-real-network-canary.sh
```
This role passes only when TestClient exits exactly `12`, emits a non-empty typed
authorization/traversal outcome, and emits the authoritative fallback category.
A timeout without the typed terminal outcome, exit `0`, direct-traffic success,
or an unbounded process is a failed canary. Restore the firewall after the drill
and verify normal traffic again.
## Private diagnostics and retention
The harness creates raw JSON events under a randomly named `0700`-equivalent
temporary directory with a process `umask` of `077`. Successful raw events are
deleted automatically. On failure they remain in that private directory so the
operator can triage locally; do not attach them to an issue before removing
listing IDs and reviewing every field. Set `RENDEZVOUS_CANARY_KEEP_RAW=true`
only for an approved short-lived diagnostic capture, then delete it manually.
The sanitized summary contains the commit, clean/dirty tree state, UTC time,
role, declared topology, observed address-family gate, aggregate booleans, and
the retention policy. Formal evidence requires the default clean-tree check.
## Public ingress proof
Success through a public hostname is insufficient proof that UDP source/reply
addressing is preserved. During a canary, an authorized operator must capture
only packet headers at the service host and verify:
1. each authenticated contribution reaches the mediator with the external peer
source tuple visible to the server;
2. introductions are sent from the same advertised public mediator tuple;
3. no load balancer, user-space proxy, service mesh, or destination NAT changes
the source or reply tuple expected by LiteNetLib; and
4. malformed or unauthenticated traffic receives no amplified response.
Store the approval, capture time window, candidate digest, topology category,
and pass/fail result. Do not retain packet payloads or peer tuples in the
repository. A failed tuple check blocks release even if one canary happened to
connect.
## Rehearsal and triage
Run the security, capacity, observability, deployment, rollback, privacy, and
incident procedures against the same immutable candidate. The independent
operator records which runbook revision they followed, start/end time, observed
alerts, recovery time, unexpected decisions, and pass/fail result. Update the
documentation and repeat any failed or ambiguous step.
Before changing the readiness record, reconcile every open roadmap issue as one
of: `blocking` with an owner and evidence needed, `accepted-v1` with a bounded
documented limitation, or `post-v1` with a filed issue. HA, active-active or
multi-region routing, relays, platform authentication, and scale above the
single-active v1 envelope are not silently accepted; each needs a traceable
post-v1 issue. The current follow-ups are relay decision [#24], HA/multi-region
shared state and routing [#28], scale beyond the measured envelope [#29], and
platform authentication adapters [#30]. Run the checker after every evidence
update. Only its `READY` result may support a production-ready claim.
[#24]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/24
[#28]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/28
[#29]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/29
[#30]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/30
+150
View File
@@ -0,0 +1,150 @@
# Releases and compatibility
Tracking: #19
Rendezvous releases are immutable, reproducible, and promoted only after the
same candidate has passed package, consumer, server, container, and staging
checks. A release consists of matching Client and Contracts NuGet packages, a
framework-dependent Linux server archive, a versioned linux/amd64 OCI image,
package/runtime and container SPDX inventories, checksums, provenance, release
notes, and signatures. No workflow publishes a `latest` tag.
## Version dimensions
The central values in `eng/Versions.props` are the authority. Client,
Contracts, and Server use SemVer. HTTP, UDP mediation, and connection-ticket
formats advance independently so a wire change cannot hide inside a package
patch release. The current machine-readable matrix is
[`compatibility.json`](compatibility.json); the authenticated operator status
endpoint exposes the server's supported window at runtime.
| Surface | Current | Compatibility rule |
| --- | ---: | --- |
| Client and Contracts | 1.0.0 | Matching exact versions; source/API breaks require a package major bump. |
| Server | 1.0.0 | Accepts Client 1.0.0 through compatible 1.x releases. |
| HTTP contract | 1 | Frozen OpenAPI, JSON vectors, and public API snapshot. |
| UDP mediation | 1 | Frozen codec vectors; incompatible bytes require UDP v2. |
| Connection ticket | 1 | A format change requires a new accepted ticket version and migration window. |
| LiteNetLib | 2.1.4 | Exact dependency; LiteNetLib 1.x is rejected by package and consumer gates. |
| Gameplay protocol | Per tenant | Exact match; Rendezvous does not translate gameplay protocols. |
`scripts/check-compatibility.sh` compares protected snapshots against the base
revision. A changed public API snapshot requires a package major increase; an
HTTP or UDP golden surface requires the corresponding contract increase. The
normal tests also compare implementation output with the current versioned
snapshots. For a deliberate break, add a new versioned contract directory and
documentation instead of replacing the prior version's evidence.
## Candidate build
From a clean tagged checkout:
```bash
./scripts/check-release-tag.sh v1.0.0
./scripts/build-release.sh 1.0.0
./scripts/verify-release.sh 1.0.0
```
The tag build runs inside the digest-pinned `release-builder` Docker stage,
which combines the pinned SDK with a pinned Python runtime. It uses the locked
dependency graph, enforces NuGet
advisories and approved licenses, runs formatting/build/tests, regenerates the
OpenAPI drift check, and packs twice after a clean rebuild. NuGet's random OPC
relationship identifiers are canonicalized before comparison; both `.nupkg`
and `.snupkg` outputs must then be byte-identical. Package metadata identifies the exact
repository commit, portable PDBs carry SourceLink data, and the Linux archive,
runtime SBOM timestamp, and checksum ordering are deterministic. Buildx and
BuildKit are also pinned for the linux/amd64 OCI build. Provenance records each
artifact-producing tool version; an out-of-band rebuild must use the pinned
builder and recorded versions rather than treating the runner label as a
reproducibility guarantee.
All project-authored artifact normalization and checksum updates run inside the
same pinned builder; host tools only orchestrate or verify. The separately
pinned Trivy and Cosign tools produce the container inventory and signatures.
The tag workflow also performs two no-cache image builds with the commit time
and revision fixed, disables unsigned builder-generated attestations, and
requires identical OCI image IDs before signing the project provenance.
The local gate builds net8.0 SpaceGame- and Unscouted-shaped API fixtures using
only the candidate feed plus NuGet.org; the Unscouted fixture also carries its
real direct LiteNetLib 2.1.4 pin. The tag gate separately checks out the exact
SpaceGame and Unscouted revisions in `eng/consumer-revisions.json`, injects
exact candidate references without modifying those repositories, and restores
their real game/network projects. Both paths must resolve the matching Client
and Contracts version and LiteNetLib 2.1.4. Updating a consumer revision is a
reviewed compatibility change, not a floating-main check.
## Promotion and publication
Pushing the matching `vMAJOR.MINOR.PATCH` tag starts the tag-only release
workflow. Before any external write it:
1. builds and verifies the artifact set;
2. builds the exact versioned container candidate;
3. starts that image with production hardening and a temporary staging key;
4. completes HTTP health, registration, browse, authenticated UDP mediation,
and direct traffic;
5. rejects all high or critical container findings and emits a container SPDX
inventory;
6. finalizes and verifies checksums over the publish-ready artifact set; and
7. confirms the two NuGet versions, container version, and Gitea release do not
already exist.
Publication has no skip-duplicate behavior. Gitea's immutable package versions,
the workflow concurrency lock, and the preflight make a successful tag a
single publication event. The workflow pushes symbols, publishes only the
versioned container tag, records its `sha256` digest in both a digest file and
provenance, regenerates the checksum manifest so that digest and public key are
covered, signs the checksum manifest and image, attaches signed provenance,
verifies the complete published-set schema and all signatures, and creates the
Gitea release with exactly those artifacts. The detached checksum signature
bundle is the sole envelope excluded from its own signed manifest.
The loaded image ID is captured immediately after the byte-reproducible build;
publication refuses to push if staging or another process retagged that local
name to different bytes.
The protected `production` environment requires these secrets:
- `RELEASE_TOKEN`: a dedicated Gitea token limited to this repository and the
HeiKyu package registry, with repository and package write access;
- `RELEASE_USERNAME`: the dedicated Gitea service-account name that owns the
release token;
- `COSIGN_PRIVATE_KEY`: the encrypted Cosign release private key; and
- `COSIGN_PASSWORD`: its password, stored separately.
No development signing key, registry credential, or deployable configuration
is stored in source or packages. Keep the Cosign public key with operational
records. Rotate the release key between releases: retain the old public key for
historical verification, install the new encrypted private key and password as
one reviewed change, verify a signed non-release blob, and only then retire the
old secret. Suspected compromise requires token/key revocation and a new
version; never overwrite or delete evidence to reuse a released version.
## Release notes and migration
Every dated `CHANGELOG.md` entry must contain Compatibility, Security and
configuration, and Migration sections. Before tagging, state the supported
Client/server window, all HTTP/UDP/ticket changes, security fixes, required
configuration, and operator/consumer migration steps.
For a protocol migration, first make the server read both old and new versions
within an explicit bounded window, publish a Client that writes the new version,
verify adoption through bounded telemetry, then remove the old reader only in a
subsequent breaking release. Never silently reinterpret old bytes. Consumers
pin both Rendezvous packages to one exact version and choose gameplay protocol
compatibility per tenant.
## Rollback and interrupted publication
Runtime rollback means redeploying the previous known-good image by digest and
its matching configuration; it does not move a tag. Packages and release
records remain available so already restored clients stay reproducible. If a
new release is faulty, revoke affected publisher or signing keys when relevant,
mark the release notes as withdrawn, and publish the fix under a new SemVer.
The registries cannot provide a transaction spanning NuGet, OCI, signatures,
and release attachments. If publication stops after its first external write,
the preflight intentionally prevents an automatic rerun. An operator must
inventory every destination, preserve logs and hashes, complete or withdraw the
partial version under change control, and then issue a new version. This avoids
turning a partial failure into an untraceable overwrite.
+27
View File
@@ -0,0 +1,27 @@
{
"schemaVersion": 1,
"release": "1.0.0",
"server": {
"minimumClientVersion": "1.0.0",
"maximumClientMajorVersion": 1
},
"packages": {
"FinalFactory.Rendezvous.Client": "1.0.0",
"FinalFactory.Rendezvous.Contracts": "1.0.0"
},
"contracts": {
"http": [1],
"udp": [1],
"connectionTicket": [1],
"gameplay": "exact-per-tenant"
},
"transport": {
"package": "LiteNetLib",
"version": "2.1.4",
"major": 2
},
"consumers": {
"SpaceGame": "net8.0",
"Unscouted": "net8.0"
}
}
+103
View File
@@ -0,0 +1,103 @@
# Hostile-input and overload protection
Tracking: #15
Rendezvous treats every public HTTP request and UDP datagram as hostile. The
server applies bounded fixed-window request budgets and concurrency ceilings in
two stages so malformed input is discarded before expensive work while valid
traffic is also isolated by its authenticated scope.
## Enforcement order
1. Kestrel and the HTTP abuse middleware cap request bodies at 16 KiB. A known
oversized body receives a typed `413` response before endpoint dispatch.
2. Every HTTP request consumes global, source-prefix, and operation budgets and
acquires the corresponding concurrency leases. IPv4 sources share a `/24`
budget and IPv6 sources share a `/56` budget; raw addresses are not retained.
Non-lease operations also consume a smaller optional-work budget, leaving a
configured global and source-prefix reserve for renew, update, and delete
operations during shedding.
Health probes use their own source-prefix budget so public API overload cannot
make a healthy instance fail its orchestrator probes, while health traffic is
still bounded.
Operator endpoints likewise use a separate bounded rate/concurrency partition
backed by the critical tracker reserve. They first require an exact source IP
from the default-deny `OperatorAllowedAddresses` policy, so public traffic
cannot spend the incident-response budget.
3. Once an endpoint has safely derived identities, it also acquires applicable
tenant, principal or capability, and listing/attempt budgets. Secret
capabilities are represented only by bounded SHA-256 fingerprints.
4. Every UDP envelope consumes global, source-prefix, and wire-operation
budgets before decoding. A structurally and cryptographically valid request
then consumes capability, role, and mediation-handle budgets before state
mutation or introduction.
5. HTTP overload returns the stable `RateLimited` error, status `429`, and a
bounded `Retry-After` value in both the header and response contract. UDP
overload and every invalid UDP input are silently dropped.
The same HTTP identity budget is computed whether or not a listing or attempt
exists. Rejection therefore does not disclose resource existence. Publisher
authentication also completes before any tenant/resource operation, while the
pre-authentication source budget prevents invalid credentials from bypassing
load shedding.
## Bounded state and recovery
`Rendezvous:AbuseProtection:MaxTrackedKeys` is a hard combined ceiling for rate
and active-concurrency keys. General HTTP and UDP traffic cannot consume the
configured `CriticalTrackedKeyReserve`; lease operations, health probes, and
allowlisted operator controls may
use that reserve but never exceed the hard ceiling. A request that would exceed
its applicable ceiling fails closed without adding state. Fixed-window rate keys
are cleared at the next window boundary; concurrency keys are removed as their
request leases finish. HTTP and UDP trackers have separate locks and cardinality
partitions, so a UDP flood cannot block HTTP admission on a shared lock or
consume HTTP key capacity. This gives
deterministic burst recovery and prevents an attacker from growing a permanent
high-cardinality address, credential, or resource table.
The complete default profile is checked into
`src/FinalFactory.Rendezvous.Server/appsettings.json`. Operators may lower or
tune limits for a measured deployment profile, but must preserve all dimensions
and leave the tracker ceiling above the maximum simultaneous key set. A rolling
deployment should use the same profile on every instance. These per-process
limits are a final service boundary; an edge proxy may add stricter distributed
limits but is not a substitute for them.
When an HTTP reverse proxy is used, every immediate proxy address must be
allowlisted in `Rendezvous:AbuseProtection:TrustedProxyAddresses` (or indexed
environment variables such as
`Rendezvous__AbuseProtection__TrustedProxyAddresses__0`). Only one forwarded
hop is accepted. With an empty allowlist, forwarded headers are ignored and the
direct TCP peer is the source. Never add a broad network range or accept
untrusted `X-Forwarded-For` input: that would let a caller choose its own rate
partition.
## Reflection, disclosure, and logging rules
- UDP sends nothing for malformed, oversized, unauthenticated, stale,
replayed, wrong-role, or rate-limited input.
- Introductions are emitted only after both role-scoped capabilities bind to
their observed gameplay-socket sources. HTTP never supplies a public
introduction target.
- Private candidates must be same-family private unicast addresses and are used
only for peers observed behind the same public address.
- Abuse keys, exceptions, and responses never include bearer credentials,
capabilities, tickets, raw endpoints, metadata values, or hostile markup.
- Endpoint and capability values are not used as metric labels or log fields.
## Verification
The deterministic test corpora use the recorded seeds `0x152026`, `0x154A50`,
and `0x1557A7E`. They exercise 10,000 arbitrary UDP envelopes through the
production decoder, 5,000 arbitrary HTTP/credential parser inputs, and 1,000
mutated state transitions, including the oversized and configured-capacity
boundaries.
Focused tests cover IPv4 and IPv6 prefix
partitioning, tenant/principal/resource concurrency, tracker exhaustion,
window recovery, wire-operation isolation, a steady-state allocation ceiling,
typed `429`/`413` responses, secret fingerprint redaction, and silent
authenticated UDP shedding. The existing state, contract, HTTP, client,
and mediator suites continue to cover cross-tenant access, replay, role swaps,
credential rotation, bounded metadata, endpoint validation, and one-shot
amplification behavior.
+1 -1
View File
@@ -11,7 +11,7 @@ backlog where the control is implemented and verified.
| Per-game credentials and signing keys | Provisioned principals and versioned keys are scoped to game/environment; secrets come from a provider and never a public binary. (#5) | Cross-tenant authorization tests, rotation/overlap/revocation tests, and secret scans. |
| Short-lived, single-purpose tokens resistant to replay | Issuer fixes audience, tenant, attempt, role, issued/expiry times, nonce, and key ID; store atomically consumes nonce/ticket. (#4, #6, #10) | Golden vectors; expired, future, mutated, wrong-role, wrong-tenant, and concurrent replay tests. |
| Strict payload, metadata, and token size limits | ADR 0003 ceilings are checked before allocation/deserialization and again at domain construction. (#4, #15) | Boundary/property tests, malformed corpus, and allocation-aware fuzzing. |
| Registration, query, and introduction rate limits | Layered per-address, principal, tenant, and global token buckets with bounded queues and stable retry guidance. (#15) | Limit partition/isolation tests and overload/soak profiles. |
| Registration, query, and introduction rate limits | Layered fixed-window budgets and concurrency leases cover global, operation, IPv4 `/24` or IPv6 `/56`, tenant, principal/capability, and listing/attempt dimensions with a bounded key table and stable retry guidance. (#15) | Deterministic partition, concurrency, tracker-exhaustion, recovery, typed-overload, and silent-UDP-shedding tests. |
| Lease expiry removes abandoned servers | Visibility and join eligibility atomically require a fresh lease and fresh authenticated presence. (#6, #7) | Fake-clock expiry, renew/expire race, restart, and stale-host join tests. |
| Validate game, environment, room, and protocol boundaries | Every identifier is a validated type; store keys and authorization decisions include server-derived tenant scope; protocol is exact-match in v1. (#4-#10) | Contract, tenant-isolation, incompatible-version, and confused-deputy tests. |
| Structured audit events without secrets or reusable credentials | Allowlisted audit schema excludes metadata values, raw endpoints, tokens, and key material; event volume is bounded. (#16) | Captured-log/audit assertions and credential canary scans. |
+87
View File
@@ -0,0 +1,87 @@
# Game provisioning and signing-key lifecycle
Tracking: #5
Rendezvous treats game and environment scope as provisioned policy, not caller
input. Production starts only when it can build an enabled policy registry and
load at least one currently active signing key from an external secret provider.
Unknown and disabled scopes fail closed.
## Policy boundary
Each `GamePolicy` fixes the allowed:
- game/environment pair and regions;
- exact gameplay protocol versions;
- publisher trust and listing visibility modes;
- metadata keys, required keys, per-value limits, total bytes, and key count;
- listing, anonymous-host, and active-attempt quotas; and
- dedicated fallback feature policy.
Publisher authorization first authenticates a typed principal, then derives the
authoritative game/environment from that principal. Request fields are compared
for mismatch detection but never replace the authenticated scope. Dedicated
workloads, short-lived player-host grants, anonymous unlisted publishers, and
operators are distinct principal types. Operator credentials cannot be used as
publisher credentials, and anonymous publishers cannot escalate to public
visibility.
## Signed credentials
Signed principal credentials use the compact form
`rv1.<key-id>.<base64url-payload>.<base64url-HMAC-SHA256>`. The signed payload
contains version, issuer, audience, subject, principal kind, bounded scope,
issued/not-before/expiry times, and a random nonce. It contains no signing key,
reusable publisher secret, player identity, or gameplay state.
Validation is deliberately ordered and bounded:
1. enforce the v1 opaque-credential length and four-segment grammar;
2. resolve a known, non-revoked key in its verification window;
3. compare the HMAC in fixed time;
4. parse canonical bounded JSON;
5. require exact version, issuer, and audience;
6. enforce clock skew, expiry, key lifetime, principal kind, and scope shape.
Failures return typed internal reasons without echoing the credential. Logs and
metrics must record only allowlisted tenant/principal/result dimensions; token,
key, secret-reference value, and raw key material are excluded.
## Rotation and revocation
A key is bound either to operator credentials only or to allowed publisher
credential kinds for exactly one game/environment. The verifier checks this
authority after the signature, so even a compromised game grant issuer cannot
mint a valid cross-game or operator credential.
A key also has three times: `NotBefore`, `SignUntil`, and `VerifyUntil`. Issuance
picks the newest authorized non-revoked key inside its signing window. Older credentials continue
to verify until the old key's verification window ends, providing an explicit
overlap. After `VerifyUntil` they fail as retired. Configuration revocation and
runtime revocation both reject immediately. A configured revoked key retains
only its public key ID/lifecycle metadata and does not require retired secret
material to remain available.
Key IDs are non-secret base64url identifiers. Secret references are resolved
through `ISecretProvider`; production supports base64 `env:<VARIABLE>` and raw
`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
`development:ephemeral/...` reference. It never writes key material to disk and
all credentials become invalid when the process exits.
## Production configuration
`Rendezvous:Provisioning` supplies issuer, audience, clock skew, signing-key
descriptors, and game policies. A production key reference such as
`env:RENDEZVOUS_SIGNING_KEY_2026_01` expects that environment variable to hold at
least 32 random bytes encoded as base64. `file:/run/secrets/rendezvous-signing`
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,
responses, logs, metrics, exceptions, or diagnostic dumps.
Readiness becomes true only after provisioning and UDP startup both succeed.
OpenAPI generation uses a pinned build-only host and does not start listeners or
bypass provisioning in a deployed server process.
+15
View File
@@ -0,0 +1,15 @@
<Project>
<PropertyGroup>
<RendezvousVersion>1.0.0</RendezvousVersion>
<RendezvousMajorVersion>1</RendezvousMajorVersion>
<RendezvousMinorVersion>0</RendezvousMinorVersion>
<RendezvousPatchVersion>0</RendezvousPatchVersion>
<MinimumClientVersion>1.0.0</MinimumClientVersion>
<MaximumClientMajorVersion>1</MaximumClientMajorVersion>
<HttpContractVersion>1</HttpContractVersion>
<UdpContractVersion>1</UdpContractVersion>
<ConnectionTicketFormatVersion>1</ConnectionTicketFormatVersion>
<LiteNetLibVersion>2.1.4</LiteNetLibVersion>
<LiteNetLibMajorVersion>2</LiteNetLibMajorVersion>
</PropertyGroup>
</Project>
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""Validate the redacted v1 readiness record and emit the release decision."""
from __future__ import annotations
import json
import pathlib
import re
import sys
from datetime import datetime, timedelta
from typing import Any
LOCAL_GATES = {
"immutable-release-artifacts",
"debug-and-release-verification",
"real-consumer-pilots",
"candidate-capacity-resilience",
"production-process-recovery",
"security-privacy-observability",
}
EXTERNAL_GATES = {
"public-package-empty-cache-restore",
"signed-publication",
"source-preserving-udp-ingress",
"same-lan-direct-canary",
"home-nat-direct-canary",
"restrictive-cgnat-typed-failure",
"firewall-blocked-udp-typed-failure",
"ipv6-direct-canary",
"public-rate-shaped-capacity",
"one-hour-candidate-endurance",
"alert-delivery",
"cold-standby-rollback-drill",
"documentation-only-runbook-exercise",
}
STATUSES = {"pass", "pending", "fail"}
FORBIDDEN_KEY_PARTS = {
"address",
"credential",
"endpoint",
"listingid",
"password",
"playerid",
"secret",
"token",
"userid",
}
UUID = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F-]{27,}\b")
IPV4 = re.compile(r"(?<![0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])")
IPV6 = re.compile(
r"(?i)(?:\b[0-9a-f]{0,4}:[0-9a-f:]*::[0-9a-f:]*\b|\b(?:[0-9a-f]{1,4}:){4,}[0-9a-f:]{1,39}\b)"
)
COMMIT = re.compile(r"[0-9a-f]{40}")
DIGEST = re.compile(r"[0-9a-f]{64}")
class InvalidRecord(ValueError):
pass
def reject_sensitive(value: Any, path: str = "$") -> None:
if isinstance(value, dict):
for key, child in value.items():
normalized = re.sub(r"[^a-z0-9]", "", key.lower())
if any(part in normalized for part in FORBIDDEN_KEY_PARTS):
raise InvalidRecord(f"{path}.{key} uses a forbidden sensitive-data key")
reject_sensitive(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
reject_sensitive(child, f"{path}[{index}]")
elif isinstance(value, str):
if UUID.search(value) or IPV4.search(value) or IPV6.search(value) \
or "://" in value or "@" in value:
raise InvalidRecord(f"{path} contains endpoint, identifier, or account-shaped data")
def evidence_path(repository_root: pathlib.Path, value: str, path: str) -> pathlib.Path:
relative = pathlib.PurePosixPath(value)
if relative.is_absolute() or ".." in relative.parts or not value:
raise InvalidRecord(f"{path} must be a repository-relative reference")
candidate = (repository_root / pathlib.Path(*relative.parts)).resolve()
if not candidate.is_relative_to(repository_root.resolve()) or not candidate.is_file():
raise InvalidRecord(f"{path} does not resolve to a repository evidence file")
return candidate
def load_json(path: pathlib.Path, label: str) -> Any:
try:
with path.open("r", encoding="utf-8") as source:
return json.load(source)
except (OSError, json.JSONDecodeError) as error:
raise InvalidRecord(f"{label} is not readable JSON: {error}") from error
def validate_gate_set(
items: Any,
expected: set[str],
path: str,
repository_root: pathlib.Path,
) -> list[dict[str, str]]:
if not isinstance(items, list):
raise InvalidRecord(f"{path} must be an array")
gates: list[dict[str, str]] = []
for index, item in enumerate(items):
if not isinstance(item, dict) or set(item) != {"id", "status", "evidenceRef", "note"}:
raise InvalidRecord(f"{path}[{index}] has an invalid shape")
if not all(isinstance(item[key], str) for key in item):
raise InvalidRecord(f"{path}[{index}] fields must be strings")
if item["status"] not in STATUSES:
raise InvalidRecord(f"{path}[{index}] has an invalid status")
evidence_path(repository_root, item["evidenceRef"], f"{path}[{index}].evidenceRef")
if len(item["note"]) > 240:
raise InvalidRecord(f"{path}[{index}].note is too long")
gates.append(item)
identifiers = [gate["id"] for gate in gates]
if len(identifiers) != len(set(identifiers)):
raise InvalidRecord(f"{path} contains duplicate gate identifiers")
if set(identifiers) != expected:
missing = sorted(expected - set(identifiers))
extra = sorted(set(identifiers) - expected)
raise InvalidRecord(f"{path} gate mismatch; missing={missing}, extra={extra}")
return gates
def validate_local_evidence(
record: dict[str, Any],
gates: list[dict[str, str]],
repository_root: pathlib.Path,
) -> None:
if any(gate["status"] != "pass" for gate in gates):
return
commit = record["evaluatedCommit"]
release_path = evidence_path(
repository_root,
"docs/evidence/releases/v1.0.0-local-candidate.json",
"local release evidence",
)
release = load_json(release_path, "local release evidence")
if not isinstance(release, dict) or release.get("schemaVersion") != 1 \
or release.get("kind") != "rendezvous-local-release-candidate" \
or release.get("sourceCommit") != commit \
or release.get("treeState") != "clean" \
or release.get("result") != "pass":
raise InvalidRecord("local release evidence is not a passing clean build of evaluatedCommit")
verification = release.get("verification")
if not isinstance(verification, dict):
raise InvalidRecord("local release evidence has no verification object")
exact_passes = {
"lockedRestore": "pass",
"format": "pass",
"byteReproduciblePackages": "pass",
"byteReproducibleServerArchive": "pass",
"sbomChecksumsAndProvenance": "pass",
"candidateConsumerFixtures": "pass",
"realConsumerRestores": "pass",
}
if any(verification.get(key) != value for key, value in exact_passes.items()) \
or verification.get("reportedVulnerabilities") != 0 \
or verification.get("releaseBuildWarnings") != 0 \
or verification.get("releaseBuildErrors") != 0 \
or verification.get("debugTestsPassed", 0) < 300 \
or verification.get("debugTestsFailed") != 0 \
or verification.get("releaseTestsPassed", 0) < 300 \
or verification.get("releaseTestsFailed") != 0 \
or verification.get("selectedProductionFaultTestsPassed", 0) < 17:
raise InvalidRecord("local release evidence does not satisfy every required verification")
consumers = release.get("consumers")
if not isinstance(consumers, list) or {
item.get("name") for item in consumers if isinstance(item, dict)
} != {"SpaceGame", "Unscouted"} or any(
not isinstance(item, dict)
or item.get("candidateRestore") != "pass"
or item.get("directTrafficPilot") != "pass"
for item in consumers
):
raise InvalidRecord("local release evidence does not prove both required consumers")
capacity_path = evidence_path(
repository_root,
"docs/evidence/capacity/v2/candidate-2cpu.json",
"candidate capacity evidence",
)
capacity = load_json(capacity_path, "candidate capacity evidence")
runtime = capacity.get("runtime") if isinstance(capacity, dict) else None
state = capacity.get("state") if isinstance(capacity, dict) else None
if not isinstance(runtime, dict) or not isinstance(state, dict) \
or capacity.get("schemaVersion") != 2 \
or capacity.get("profile") != "candidate" \
or capacity.get("passed") is not True \
or capacity.get("failures") != [] \
or runtime.get("commitSha") != commit \
or runtime.get("treeState") != "clean" \
or runtime.get("processorCount") != 2 \
or state.get("soakDurationSeconds", 0) < 300 \
or state.get("finalListings") != 0 \
or state.get("finalAttempts") != 0 \
or state.get("finalReplayMarkers") != 0 \
or state.get("restartStartedEmpty") is not True \
or state.get("overloadWasTyped") is not True \
or state.get("recoverySucceeded") is not True:
raise InvalidRecord("candidate capacity evidence does not satisfy the clean evaluated commit")
def validate_external_attestations(
record: dict[str, Any],
gates: list[dict[str, str]],
repository_root: pathlib.Path,
) -> None:
for gate in gates:
if gate["status"] != "pass":
continue
path = evidence_path(repository_root, gate["evidenceRef"], f"{gate['id']} evidence")
attestation = load_json(path, f"{gate['id']} evidence")
if not isinstance(attestation, dict) or set(attestation) != {
"schemaVersion",
"kind",
"gateId",
"candidateCommit",
"result",
"performedAtUtc",
"artifactDigest",
"evidenceLocation",
"reviewerRole",
}:
raise InvalidRecord(f"{gate['id']} requires a complete external-gate attestation")
reject_sensitive(attestation, f"external evidence {gate['id']}")
if attestation["schemaVersion"] != 1 \
or attestation["kind"] != "rendezvous-external-gate-attestation" \
or attestation["gateId"] != gate["id"] \
or attestation["candidateCommit"] != record["evaluatedCommit"] \
or attestation["result"] != "pass" \
or not isinstance(attestation["artifactDigest"], str) \
or not DIGEST.fullmatch(attestation["artifactDigest"]) \
or attestation["evidenceLocation"] not in {
"protected-operations-record",
"public-release-record",
} \
or attestation["reviewerRole"] not in {
"release-operator",
"network-operator",
"security-operator",
"independent-operator",
}:
raise InvalidRecord(f"{gate['id']} external attestation does not match the candidate gate")
try:
performed = datetime.fromisoformat(attestation["performedAtUtc"].replace("Z", "+00:00"))
except (AttributeError, ValueError) as error:
raise InvalidRecord(f"{gate['id']} has an invalid performedAtUtc") from error
if performed.tzinfo is None or performed.utcoffset() != timedelta(0):
raise InvalidRecord(f"{gate['id']} performedAtUtc must be UTC")
def validate(record: Any, repository_root: pathlib.Path) -> tuple[bool, list[str]]:
if not isinstance(record, dict) or set(record) != {
"schemaVersion",
"kind",
"evaluatedCommit",
"decision",
"localGates",
"externalGates",
}:
raise InvalidRecord("The top-level readiness record shape is invalid")
if record["schemaVersion"] != 1 or record["kind"] != "rendezvous-production-readiness":
raise InvalidRecord("The readiness schema identity is invalid")
if not isinstance(record["evaluatedCommit"], str) or not COMMIT.fullmatch(record["evaluatedCommit"]):
raise InvalidRecord("evaluatedCommit must be a full lowercase Git commit")
reject_sensitive(record)
local_gates = validate_gate_set(
record["localGates"], LOCAL_GATES, "$.localGates", repository_root
)
external_gates = validate_gate_set(
record["externalGates"], EXTERNAL_GATES, "$.externalGates", repository_root
)
validate_local_evidence(record, local_gates, repository_root)
validate_external_attestations(record, external_gates, repository_root)
gates = local_gates + external_gates
blockers = sorted(gate["id"] for gate in gates if gate["status"] != "pass")
ready = not blockers
expected_decision = "ready" if ready else "not-ready"
if record["decision"] != expected_decision:
raise InvalidRecord(
f"decision must be {expected_decision!r} for the recorded gate statuses"
)
return ready, blockers
def main() -> int:
if len(sys.argv) != 2:
print("usage: check_production_readiness.py RECORD", file=sys.stderr)
return 2
try:
with open(sys.argv[1], "r", encoding="utf-8") as source:
record = json.load(source)
ready, blockers = validate(record, pathlib.Path(__file__).resolve().parent.parent)
except (OSError, json.JSONDecodeError, InvalidRecord) as error:
print(f"INVALID: {error}", file=sys.stderr)
return 2
if not ready:
print(f"NOT READY: {len(blockers)} required gate(s) are not passing.")
for blocker in blockers:
print(f"- {blocker}")
return 3
print("READY: every required v1 production gate is recorded as passing.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"consumers": [
{
"name": "SpaceGame",
"repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git",
"revision": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
"project": "SpaceGame.csproj"
},
{
"name": "Unscouted",
"repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git",
"revision": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
"project": "Net.Core/Net.Core.csproj"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM python:3.12.11-slim-bookworm@sha256:c00fc7b44d844b6da22861ec24af43968a5200eac4ec607b4725d585165d6b49 AS release-python
FROM ghcr.io/jqlang/jq:1.8.1@sha256:95de8f005ca027686a1ca3b0853e2bb219062438015862816159f3f25a4d4230 AS release-jq
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS release-builder
COPY --from=release-python /usr/local/ /usr/local/
COPY --from=release-jq /jq /usr/local/bin/jq
RUN dotnet --version \
&& python3 --version \
&& git --version \
&& tar --version \
&& gzip --version \
&& jq --version
WORKDIR /source
+27
View File
@@ -0,0 +1,27 @@
{
"schemaVersion": 1,
"packageRegistry": "https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json",
"containerRepository": "git.finalfactory.de/heikyu/rendezvous",
"allowedLicenseExpressions": [
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"MIT"
],
"dependencyLicenseOverrides": {
"xunit.abstractions/2.0.3": {
"license": "Apache-2.0",
"reason": "Legacy package predates NuGet SPDX metadata; reviewed against the xUnit.net Apache-2.0 license."
}
},
"publishedPackages": [
"FinalFactory.Rendezvous.Client",
"FinalFactory.Rendezvous.Contracts"
],
"forbiddenArtifactNameFragments": [
"credential",
"password",
"private-key",
"signing-key"
]
}
+823
View File
@@ -0,0 +1,823 @@
#!/usr/bin/env python3
import argparse
import datetime as dt
import hashlib
import json
import os
import pathlib
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape
PROJECT_PACKAGE_PREFIX = "finalfactory.rendezvous."
CORE_PROPERTIES_PATH = (
"package/services/metadata/core-properties/core-properties.psmdcp"
)
def fail(message: str) -> None:
raise SystemExit(message)
def load_json(path: pathlib.Path):
with path.open(encoding="utf-8") as stream:
return json.load(stream)
def dependency_inventory(root: pathlib.Path):
dependencies = {}
for lock_path in sorted(root.glob("**/packages.lock.json")):
if any(part in {"bin", "obj", "artifacts"} for part in lock_path.parts):
continue
lock = load_json(lock_path)
for framework in lock.get("dependencies", {}).values():
for package_id, details in framework.items():
resolved = details.get("resolved")
if not resolved or package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
continue
key = package_id.lower()
previous = dependencies.get(key)
if previous is not None and previous[1] != resolved:
fail(
f"Dependency {package_id} resolves to both {previous[1]} and {resolved}."
)
dependencies[key] = (package_id, resolved)
return [dependencies[key] for key in sorted(dependencies)]
def package_license(package_id: str, version: str, overrides):
package_root = pathlib.Path(
os.environ.get("NUGET_PACKAGES", pathlib.Path.home() / ".nuget" / "packages")
)
version_dir = package_root / package_id.lower() / version
nuspecs = list(version_dir.glob("*.nuspec"))
if len(nuspecs) != 1:
fail(f"Expected one restored nuspec for {package_id} {version} in {version_dir}.")
root = ET.parse(nuspecs[0]).getroot()
license_element = root.find(".//{*}license")
if license_element is None or license_element.get("type") != "expression":
override = overrides.get(f"{package_id.lower()}/{version}")
if override is None:
fail(f"{package_id} {version} does not declare an SPDX license expression.")
return override["license"]
expression = (license_element.text or "").strip()
if not expression:
fail(f"{package_id} {version} has an empty license expression.")
return expression
def command_policy(args) -> None:
root = pathlib.Path(args.root).resolve()
policy = load_json(root / "eng" / "release-policy.json")
allowed = set(policy["allowedLicenseExpressions"])
inventory = dependency_inventory(root)
observed = []
for package_id, version in inventory:
expression = package_license(
package_id, version, policy.get("dependencyLicenseOverrides", {})
)
if expression not in allowed:
fail(
f"Dependency {package_id} {version} uses unapproved license {expression}."
)
observed.append({"id": package_id, "version": version, "license": expression})
lite_net_lib = [item for item in observed if item["id"].lower() == "litenetlib"]
if lite_net_lib != [{"id": "LiteNetLib", "version": "2.1.4", "license": "MIT"}]:
fail(f"LiteNetLib must resolve exactly to the reviewed 2.1.4 release: {lite_net_lib}")
print(json.dumps({"dependencies": observed}, indent=2))
def command_audit(args) -> None:
document = load_json(pathlib.Path(args.input))
findings = []
def visit(value):
if isinstance(value, dict):
if value.get("vulnerabilities"):
findings.append(value)
for child in value.values():
visit(child)
elif isinstance(value, list):
for child in value:
visit(child)
visit(document)
if findings:
fail(f"Locked dependency graph contains known vulnerabilities: {findings}")
print("Locked dependency graph has no reported vulnerabilities.")
def release_dependency_graph(root: pathlib.Path, server_deps: pathlib.Path):
dependencies = {}
edges = set()
server_document = load_json(server_deps)
for package_key, details in server_document.get("libraries", {}).items():
if details.get("type") != "package":
continue
package_id, version = package_key.rsplit("/", 1)
dependencies[package_id.lower()] = (package_id, version)
server_target = next(iter(server_document.get("targets", {}).values()), {})
for source_key, details in server_target.items():
source_id = source_key.rsplit("/", 1)[0]
source = (
"SPDXRef-Server"
if source_id == "FinalFactory.Rendezvous.Server"
else source_id.lower()
)
for dependency_id in details.get("dependencies", {}):
target = {
"FinalFactory.Rendezvous.Contracts": "SPDXRef-Contracts",
"FinalFactory.Rendezvous.Client": "SPDXRef-Client",
}.get(dependency_id, dependency_id.lower())
edges.add((source, target))
lock_roots = (
("SPDXRef-Client", root / "src/FinalFactory.Rendezvous.Client/packages.lock.json"),
(
"SPDXRef-Contracts",
root / "src/FinalFactory.Rendezvous.Contracts/packages.lock.json",
),
)
for root_id, lock_path in lock_roots:
lock = load_json(lock_path)
for framework in lock.get("dependencies", {}).values():
for package_id, details in framework.items():
resolved = details.get("resolved")
if resolved and not package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
dependencies[package_id.lower()] = (package_id, resolved)
if details.get("type") == "Direct":
edges.add((root_id, package_id.lower()))
source = package_id.lower()
for dependency_id in details.get("dependencies", {}):
if dependency_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
continue
edges.add((source, dependency_id.lower()))
edges.add(("SPDXRef-Client", "SPDXRef-Contracts"))
inventory = [dependencies[key] for key in sorted(dependencies)]
return inventory, edges
def command_sbom(args) -> None:
root = pathlib.Path(args.root).resolve()
policy = load_json(root / "eng" / "release-policy.json")
overrides = policy.get("dependencyLicenseOverrides", {})
packages = [
{
"SPDXID": "SPDXRef-Rendezvous",
"name": "FinalFactory.Rendezvous",
"versionInfo": args.version,
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
}
]
internal_packages = [
("SPDXRef-Server", "FinalFactory.Rendezvous.Server"),
("SPDXRef-Client", "FinalFactory.Rendezvous.Client"),
("SPDXRef-Contracts", "FinalFactory.Rendezvous.Contracts"),
]
for spdx_id, package_id in internal_packages:
packages.append(
{
"SPDXID": spdx_id,
"name": package_id,
"versionInfo": args.version,
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
}
)
inventory, dependency_edges = release_dependency_graph(
root, pathlib.Path(args.server_deps)
)
dependency_ids = {}
for index, (package_id, version) in enumerate(
inventory, start=1
):
expression = package_license(package_id, version, overrides)
spdx_id = f"SPDXRef-Package-{index}"
dependency_ids[package_id.lower()] = spdx_id
packages.append(
{
"SPDXID": spdx_id,
"name": package_id,
"versionInfo": version,
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"licenseConcluded": expression,
"licenseDeclared": expression,
"copyrightText": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": f"pkg:nuget/{package_id}@{version}",
}
],
}
)
created = dt.datetime.fromtimestamp(
int(args.source_date_epoch), dt.timezone.utc
).strftime("%Y-%m-%dT%H:%M:%SZ")
document = {
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": f"FinalFactory.Rendezvous-{args.version}",
"documentNamespace": (
"https://git.finalfactory.de/HeiKyu/Rendezvous/sbom/"
f"{args.version}/{args.commit}"
),
"creationInfo": {
"created": created,
"creators": ["Tool: eng/release_artifacts.py"],
},
"documentDescribes": ["SPDXRef-Rendezvous"],
"packages": packages,
"relationships": [
{
"spdxElementId": "SPDXRef-Rendezvous",
"relationshipType": "CONTAINS",
"relatedSpdxElement": spdx_id,
}
for spdx_id, _ in internal_packages
]
+ [
{
"spdxElementId": dependency_ids.get(source, source),
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": dependency_ids.get(target, target),
}
for source, target in sorted(dependency_edges)
if source in dependency_ids or source.startswith("SPDXRef-")
if target in dependency_ids or target.startswith("SPDXRef-")
],
}
output = pathlib.Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
def nuspec_metadata(archive: pathlib.Path):
with zipfile.ZipFile(archive) as package:
names = package.namelist()
nuspecs = [name for name in names if name.endswith(".nuspec")]
if len(nuspecs) != 1:
fail(f"{archive.name} must contain exactly one nuspec.")
root = ET.fromstring(package.read(nuspecs[0]))
metadata = root.find(".//{*}metadata")
if metadata is None:
fail(f"{archive.name} has no package metadata.")
values = {
child.tag.rsplit("}", 1)[-1]: (child.text or "").strip()
for child in metadata
if len(child) == 0
}
dependencies = {
item.get("id"): item.get("version")
for item in metadata.findall(".//{*}dependency")
}
repository = metadata.find("./{*}repository")
repository_attributes = {} if repository is None else dict(repository.attrib)
return values, dependencies, repository_attributes
def verify_checksum_file(release_dir: pathlib.Path, excluded=()) -> None:
checksum_path = release_dir / "checksums.sha256"
lines = checksum_path.read_text(encoding="utf-8").splitlines()
if not lines:
fail("checksums.sha256 is empty.")
referenced = set()
for line in lines:
digest, marker, relative = line.partition(" ")
if marker != " " or not re.fullmatch(r"[0-9a-f]{64}", digest):
fail(f"Malformed checksum line: {line}")
target = release_dir / relative
if not target.is_file():
fail(f"Checksum references missing artifact: {relative}")
actual = hashlib.sha256(target.read_bytes()).hexdigest()
if actual != digest:
fail(f"Checksum mismatch for {relative}.")
referenced.add(relative)
expected = {
path.name
for path in release_dir.iterdir()
if path.is_file()
and path.name != "checksums.sha256"
and path.name not in excluded
}
if referenced != expected:
fail(
"Checksum manifest coverage differs. "
f"Missing={expected - referenced}; extra={referenced - expected}"
)
def command_verify(args) -> None:
release_dir = pathlib.Path(args.release_dir).resolve()
version = args.version
expected = {
f"FinalFactory.Rendezvous.Client.{version}.nupkg",
f"FinalFactory.Rendezvous.Client.{version}.snupkg",
f"FinalFactory.Rendezvous.Contracts.{version}.nupkg",
f"FinalFactory.Rendezvous.Contracts.{version}.snupkg",
f"FinalFactory.Rendezvous.Server.{version}.linux-x64.tar.gz",
f"FinalFactory.Rendezvous.{version}.spdx.json",
"CHANGELOG.md",
"checksums.sha256",
"release-provenance.json",
}
checksum_exclusions = set()
if args.phase in {"publish-ready", "signing-ready", "published"}:
expected.add(f"FinalFactory.Rendezvous.Container.{version}.spdx.json")
if args.phase in {"signing-ready", "published"}:
expected.update({"container-digest.txt", "cosign.pub"})
if args.phase == "published":
expected.add("checksums.sha256.bundle")
checksum_exclusions.add("checksums.sha256.bundle")
actual = {path.name for path in release_dir.iterdir() if path.is_file()}
if actual != expected:
fail(f"Release artifact set differs. Missing={expected - actual}; extra={actual - expected}")
if args.phase in {"publish-ready", "signing-ready", "published"}:
container_sbom = load_json(
release_dir / f"FinalFactory.Rendezvous.Container.{version}.spdx.json"
)
if container_sbom.get("spdxVersion") != "SPDX-2.3":
fail("Container inventory must be an SPDX 2.3 document.")
if not container_sbom.get("packages"):
fail("Container SPDX inventory contains no packages.")
policy = load_json(pathlib.Path(args.root) / "eng" / "release-policy.json")
forbidden = tuple(fragment.lower() for fragment in policy["forbiddenArtifactNameFragments"])
for artifact in actual:
if artifact != "release-provenance.json" and any(fragment in artifact.lower() for fragment in forbidden):
fail(f"Forbidden secret-like artifact name: {artifact}")
release_sbom = load_json(
release_dir / f"FinalFactory.Rendezvous.{version}.spdx.json"
)
package_ids = {
package.get("name"): package.get("SPDXID")
for package in release_sbom.get("packages", [])
}
relationships = {
(
relationship.get("spdxElementId"),
relationship.get("relationshipType"),
relationship.get("relatedSpdxElement"),
)
for relationship in release_sbom.get("relationships", [])
}
expected_component_edges = {
("SPDXRef-Server", "SPDXRef-Contracts"),
("SPDXRef-Server", package_ids.get("LiteNetLib")),
("SPDXRef-Server", package_ids.get("Microsoft.AspNetCore.OpenApi")),
("SPDXRef-Client", "SPDXRef-Contracts"),
("SPDXRef-Client", package_ids.get("LiteNetLib")),
("SPDXRef-Contracts", package_ids.get("System.Text.Json")),
}
for source, target in expected_component_edges:
if not target or (source, "DEPENDS_ON", target) not in relationships:
fail(f"Release SPDX inventory is missing component edge {source} -> {target}.")
bcl_id = package_ids.get("Microsoft.Bcl.AsyncInterfaces")
if ("SPDXRef-Server", "DEPENDS_ON", bcl_id) in relationships:
fail("Release SPDX inventory incorrectly flattens transitive dependencies onto Server.")
for package_id in policy["publishedPackages"]:
package = release_dir / f"{package_id}.{version}.nupkg"
metadata, dependencies, repository = nuspec_metadata(package)
if metadata.get("id") != package_id or metadata.get("version") != version:
fail(f"{package.name} identity/version metadata is incorrect.")
if metadata.get("projectUrl") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
fail(f"{package.name} has an incorrect project URL.")
if repository.get("url") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
fail(f"{package.name} has an incorrect repository URL.")
if repository.get("commit") != provenance_commit(release_dir):
fail(f"{package.name} does not identify the release commit.")
symbol_package = release_dir / f"{package_id}.{version}.snupkg"
with zipfile.ZipFile(symbol_package) as symbols:
if not any(name.endswith(".pdb") for name in symbols.namelist()):
fail(f"{symbol_package.name} contains no portable PDB.")
with zipfile.ZipFile(package) as archive:
names = set(archive.namelist())
required_entries = {
"README.md",
"CHANGELOG.md",
f"lib/netstandard2.1/{package_id}.dll",
}
if not required_entries <= names:
fail(
f"{package.name} is missing required package content: "
f"{required_entries - names}"
)
forbidden_entries = [
name
for name in names
if any(
fragment in name.lower()
for fragment in (
"appsettings",
"launchsettings",
".env",
"credential",
"signing-key",
"private-key",
)
)
]
if forbidden_entries:
fail(
f"{package.name} contains deployable configuration or secrets: "
f"{forbidden_entries}"
)
if package_id.endswith(".Client"):
if dependencies.get("LiteNetLib") != "[2.1.4]":
fail(f"Client package must pin LiteNetLib exactly to 2.1.4: {dependencies}")
contracts_range = dependencies.get("FinalFactory.Rendezvous.Contracts", "")
if contracts_range != f"[{version}]":
fail(f"Client package does not depend on the matching Contracts version.")
provenance = load_json(release_dir / "release-provenance.json")
if provenance.get("version") != version:
fail("Release provenance does not identify the requested version.")
if provenance.get("treeState") != "clean" and not args.allow_dirty:
fail("Release provenance must identify a clean tree.")
container = provenance.get("containerImage", "")
if container.endswith(":latest") or ":latest@" in container or f":{version}" not in container:
fail(f"Container reference is mutable or not versioned: {container}")
if provenance.get("containerPlatform") != "linux/amd64":
fail("Release provenance must pin the linux/amd64 container platform.")
for field in ("containerBaseDigests", "releaseBuilderBaseDigests"):
images = provenance.get(field, [])
if not images or any("@sha256:" not in image or image.endswith(":latest") for image in images):
fail(f"Release provenance contains an unpinned build image in {field}: {images}")
build_tools = provenance.get("buildTools", [])
required_tool_prefixes = ("dotnet", "python=", "tar=", "gzip=", "jq=")
observed_tools = [f"dotnet={provenance.get('dotnetSdk', '')}", *build_tools]
for prefix in required_tool_prefixes:
if not any(tool.startswith(prefix) for tool in observed_tools):
fail(f"Release provenance is missing an artifact tool version: {prefix}")
if args.phase in {"publish-ready", "signing-ready", "published"}:
if not re.fullmatch(
r"sha256:[0-9a-f]{64}", provenance.get("containerImageId", "")
):
fail("Release provenance is missing the verified local container image ID.")
expected_namespace = (
"https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
f"{version}/{provenance_commit(release_dir)}"
)
if container_sbom.get("documentNamespace") != expected_namespace:
fail("Container SPDX inventory does not identify the release commit.")
expected_created = dt.datetime.fromtimestamp(
int(provenance.get("sourceDateEpoch", 0)), dt.timezone.utc
).strftime("%Y-%m-%dT%H:%M:%SZ")
if container_sbom.get("creationInfo", {}).get("created") != expected_created:
fail("Container SPDX timestamp is not normalized to the source epoch.")
if args.phase in {"signing-ready", "published"}:
digest = (release_dir / "container-digest.txt").read_text(
encoding="utf-8"
).strip()
if not re.fullmatch(
r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}", digest
):
fail(f"Published container digest is invalid: {digest}")
if provenance.get("containerDigest") != digest:
fail("Published provenance and container digest file differ.")
for prefix in ("docker-buildx=", "buildkit="):
if not any(tool.startswith(prefix) for tool in build_tools):
fail(f"Published provenance is missing container tool version: {prefix}")
verify_checksum_file(release_dir, checksum_exclusions)
print(f"Verified {args.phase} release artifact set for {version}.")
def provenance_commit(release_dir: pathlib.Path) -> str:
provenance = load_json(release_dir / "release-provenance.json")
commit = provenance.get("commit", "")
if not re.fullmatch(r"[0-9a-f]{40}", commit):
fail("Release provenance must contain a full Git commit SHA.")
return commit
def command_consumer(args) -> None:
assets = load_json(pathlib.Path(args.assets))
libraries = assets.get("libraries", {})
required = {
f"FinalFactory.Rendezvous.Client/{args.version}",
f"FinalFactory.Rendezvous.Contracts/{args.version}",
"LiteNetLib/2.1.4",
}
missing = required - set(libraries)
if missing:
fail(f"Consumer restore is missing exact release dependencies: {missing}")
forbidden = [name for name in libraries if name.lower().startswith("litenetlib/1.")]
if forbidden:
fail(f"Consumer resolved forbidden LiteNetLib 1.x assets: {forbidden}")
def command_consumer_config(args) -> None:
local_source = escape(str(pathlib.Path(args.local_source).resolve()))
configuration = f'''<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="rendezvous-candidate" value="{local_source}" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="rendezvous-candidate">
<package pattern="FinalFactory.Rendezvous.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
'''
pathlib.Path(args.output).write_text(configuration, encoding="utf-8")
def command_source_link(args) -> None:
document = load_json(pathlib.Path(args.file))
mappings = document.get("documents", {})
expected = (
"https://git.finalfactory.de/HeiKyu/Rendezvous/raw/commit/"
f"{args.commit}/"
)
if not mappings or any(not value.startswith(expected) for value in mappings.values()):
fail(f"SourceLink mappings do not identify commit {args.commit}: {mappings}")
def command_normalize_package(args) -> None:
package_path = pathlib.Path(args.package).resolve()
if package_path.suffix not in {".nupkg", ".snupkg"}:
fail(f"Unsupported NuGet archive extension: {package_path.name}")
timestamp = dt.datetime.fromtimestamp(
int(args.source_date_epoch), dt.timezone.utc
)
zip_timestamp = (
max(timestamp.year, 1980),
timestamp.month,
timestamp.day,
timestamp.hour,
timestamp.minute,
timestamp.second - (timestamp.second % 2),
)
with zipfile.ZipFile(package_path) as source:
entries = {name: source.read(name) for name in source.namelist()}
if ".signature.p7s" in entries:
fail(f"Refusing to rewrite signed package: {package_path.name}")
core_paths = [
name
for name in entries
if name.startswith("package/services/metadata/core-properties/")
and name.endswith(".psmdcp")
]
if len(core_paths) != 1:
fail(f"Expected one NuGet core-properties part in {package_path.name}.")
entries[CORE_PROPERTIES_PATH] = entries.pop(core_paths[0])
nuspec_paths = [name for name in entries if name.endswith(".nuspec")]
if len(nuspec_paths) != 1:
fail(f"Expected one nuspec in {package_path.name}.")
nuspec = ET.fromstring(entries[nuspec_paths[0]])
nuspec_namespace = nuspec.tag.partition("}")[0].removeprefix("{")
if nuspec_namespace:
ET.register_namespace("", nuspec_namespace)
package_id = nuspec.findtext(".//{*}id")
if package_id == "FinalFactory.Rendezvous.Client":
contracts = nuspec.find(
".//{*}dependency[@id='FinalFactory.Rendezvous.Contracts']"
)
if contracts is None:
fail("Client package has no Contracts dependency to pin.")
contracts.set("version", f"[{args.version}]")
entries[nuspec_paths[0]] = ET.tostring(
nuspec, encoding="utf-8", xml_declaration=True
)
relationships_namespace = (
"http://schemas.openxmlformats.org/package/2006/relationships"
)
ET.register_namespace("", relationships_namespace)
relationships = ET.fromstring(entries["_rels/.rels"])
for relationship in relationships:
relationship_type = relationship.get("Type", "")
if relationship_type.endswith("/manifest"):
relationship.set("Id", "RManifest")
elif relationship_type.endswith("/metadata/core-properties"):
relationship.set("Id", "RCoreProperties")
relationship.set("Target", f"/{CORE_PROPERTIES_PATH}")
entries["_rels/.rels"] = ET.tostring(
relationships, encoding="utf-8", xml_declaration=True
)
temporary = package_path.with_suffix(package_path.suffix + ".normalized")
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_STORED) as target:
for name in sorted(entries):
info = zipfile.ZipInfo(name, date_time=zip_timestamp)
info.compress_type = zipfile.ZIP_STORED
info.create_system = 3
info.external_attr = 0o100644 << 16
target.writestr(info, entries[name])
temporary.replace(package_path)
def command_provenance(args) -> None:
versions = ET.parse(pathlib.Path(args.root) / "eng" / "Versions.props")
def version_property(name: str) -> str:
element = versions.find(f".//{name}")
if element is None or not element.text:
fail(f"Missing central release property: {name}")
return element.text.strip()
provenance = {
"schemaVersion": 1,
"version": args.version,
"commit": args.commit,
"treeState": args.tree_state,
"buildConfiguration": "Release",
"sourceDateEpoch": int(args.source_date_epoch),
"dotnetSdk": args.dotnet_sdk,
"buildTools": sorted(args.build_tool),
"containerImage": f"git.finalfactory.de/heikyu/rendezvous:{args.version}",
"containerPlatform": "linux/amd64",
"containerBaseDigests": args.base_digest,
"releaseBuilderBaseDigests": args.builder_base,
"packages": [
f"FinalFactory.Rendezvous.Client/{args.version}",
f"FinalFactory.Rendezvous.Contracts/{args.version}",
],
"compatibility": {
"minimumClientVersion": version_property("MinimumClientVersion"),
"maximumClientMajorVersion": int(version_property("MaximumClientMajorVersion")),
"httpContractVersions": [int(version_property("HttpContractVersion"))],
"udpContractVersions": [int(version_property("UdpContractVersion"))],
"connectionTicketFormatVersions": [
int(version_property("ConnectionTicketFormatVersion"))
],
"liteNetLib": version_property("LiteNetLibVersion"),
"gameplayProtocol": "exact-per-tenant",
},
}
pathlib.Path(args.output).write_text(
json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
)
def command_record_container_build(args) -> None:
path = pathlib.Path(args.provenance)
provenance = load_json(path)
tools = set(provenance.get("buildTools", []))
tools.add(f"docker-buildx={args.buildx_version}")
tools.add(f"buildkit={args.buildkit_version}")
provenance["buildTools"] = sorted(tools)
provenance["containerPlatform"] = "linux/amd64"
if not re.fullmatch(r"sha256:[0-9a-f]{64}", args.image_id):
fail(f"Container image ID is invalid: {args.image_id}")
provenance["containerImageId"] = args.image_id
path.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
def command_record_container_digest(args) -> None:
if not re.fullmatch(
r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}",
args.digest,
):
fail(f"Published container digest is invalid: {args.digest}")
release_dir = pathlib.Path(args.release_dir)
provenance_path = release_dir / "release-provenance.json"
provenance = load_json(provenance_path)
provenance["containerDigest"] = args.digest
provenance_path.write_text(
json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
)
(release_dir / "container-digest.txt").write_text(
args.digest + "\n", encoding="utf-8"
)
def command_normalize_container_sbom(args) -> None:
path = pathlib.Path(args.file)
document = load_json(path)
created = dt.datetime.fromtimestamp(
int(args.source_date_epoch), dt.timezone.utc
).strftime("%Y-%m-%dT%H:%M:%SZ")
document["name"] = f"FinalFactory.Rendezvous.Container-{args.version}"
document["documentNamespace"] = (
"https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
f"{args.version}/{args.commit}"
)
creation = document.setdefault("creationInfo", {})
creation["created"] = created
creation["creators"] = ["Tool: Trivy-0.69.3"]
if isinstance(document.get("packages"), list):
document["packages"] = sorted(
document["packages"],
key=lambda item: (
item.get("SPDXID", ""),
item.get("name", ""),
item.get("versionInfo", ""),
),
)
if isinstance(document.get("relationships"), list):
document["relationships"] = sorted(
document["relationships"],
key=lambda item: (
item.get("spdxElementId", ""),
item.get("relationshipType", ""),
item.get("relatedSpdxElement", ""),
),
)
path.write_text(
json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
def main() -> None:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
policy = subparsers.add_parser("policy")
policy.add_argument("--root", required=True)
policy.set_defaults(handler=command_policy)
audit = subparsers.add_parser("audit")
audit.add_argument("--input", required=True)
audit.set_defaults(handler=command_audit)
sbom = subparsers.add_parser("sbom")
sbom.add_argument("--root", required=True)
sbom.add_argument("--version", required=True)
sbom.add_argument("--commit", required=True)
sbom.add_argument("--source-date-epoch", required=True)
sbom.add_argument("--server-deps", required=True)
sbom.add_argument("--output", required=True)
sbom.set_defaults(handler=command_sbom)
verify = subparsers.add_parser("verify")
verify.add_argument("--root", required=True)
verify.add_argument("--release-dir", required=True)
verify.add_argument("--version", required=True)
verify.add_argument("--allow-dirty", action="store_true")
verify.add_argument(
"--phase",
choices=("build", "publish-ready", "signing-ready", "published"),
default="build",
)
verify.set_defaults(handler=command_verify)
consumer = subparsers.add_parser("consumer")
consumer.add_argument("--assets", required=True)
consumer.add_argument("--version", required=True)
consumer.set_defaults(handler=command_consumer)
consumer_config = subparsers.add_parser("consumer-config")
consumer_config.add_argument("--local-source", required=True)
consumer_config.add_argument("--output", required=True)
consumer_config.set_defaults(handler=command_consumer_config)
source_link = subparsers.add_parser("source-link")
source_link.add_argument("--file", required=True)
source_link.add_argument("--commit", required=True)
source_link.set_defaults(handler=command_source_link)
normalize = subparsers.add_parser("normalize-package")
normalize.add_argument("--package", required=True)
normalize.add_argument("--source-date-epoch", required=True)
normalize.add_argument("--version", required=True)
normalize.set_defaults(handler=command_normalize_package)
provenance = subparsers.add_parser("provenance")
provenance.add_argument("--root", required=True)
provenance.add_argument("--version", required=True)
provenance.add_argument("--commit", required=True)
provenance.add_argument("--tree-state", required=True)
provenance.add_argument("--source-date-epoch", required=True)
provenance.add_argument("--dotnet-sdk", required=True)
provenance.add_argument("--build-tool", action="append", default=[])
provenance.add_argument("--base-digest", action="append", default=[])
provenance.add_argument("--builder-base", action="append", default=[])
provenance.add_argument("--output", required=True)
provenance.set_defaults(handler=command_provenance)
record_container = subparsers.add_parser("record-container-build")
record_container.add_argument("--provenance", required=True)
record_container.add_argument("--buildx-version", required=True)
record_container.add_argument("--buildkit-version", required=True)
record_container.add_argument("--image-id", required=True)
record_container.set_defaults(handler=command_record_container_build)
record_digest = subparsers.add_parser("record-container-digest")
record_digest.add_argument("--release-dir", required=True)
record_digest.add_argument("--digest", required=True)
record_digest.set_defaults(handler=command_record_container_digest)
normalize_container_sbom = subparsers.add_parser("normalize-container-sbom")
normalize_container_sbom.add_argument("--file", required=True)
normalize_container_sbom.add_argument("--version", required=True)
normalize_container_sbom.add_argument("--commit", required=True)
normalize_container_sbom.add_argument("--source-date-epoch", required=True)
normalize_container_sbom.set_defaults(handler=command_normalize_container_sbom)
args = parser.parse_args()
args.handler(args)
if __name__ == "__main__":
main()
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:-}"
output="${2:-}"
property() {
sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p" "$root/eng/Versions.props"
}
if [[ -z "$version" ]]; then
version="$(property RendezvousVersion)"
fi
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?(\+([0-9A-Za-z-]+\.)*[0-9A-Za-z-]+)?$'
if [[ ! "$version" =~ $semver ]]; then
echo "Release version is not valid SemVer: $version" >&2
exit 1
fi
prerelease="${version%%+*}"
if [[ "$prerelease" == *-* ]]; then
prerelease="${prerelease#*-}"
IFS='.' read -r -a prerelease_identifiers <<<"$prerelease"
for identifier in "${prerelease_identifiers[@]}"; do
if [[ "$identifier" =~ ^[0-9]+$ && ! "$identifier" =~ ^(0|[1-9][0-9]*)$ ]]; then
echo "Numeric prerelease identifiers must not contain leading zeroes: $version" >&2
exit 1
fi
done
fi
if [[ "$version" != "$(property RendezvousVersion)" ]]; then
echo "Requested version $version differs from eng/Versions.props." >&2
exit 1
fi
for command in dotnet git python3 tar gzip sha256sum cmp jq; do
command -v "$command" >/dev/null || {
echo "Required release command is unavailable: $command" >&2
exit 1
}
done
commit="$(git -C "$root" rev-parse HEAD)"
source_date_epoch="$(git -C "$root" show -s --format=%ct "$commit")"
tree_state=clean
if [[ -n "$(git -C "$root" status --porcelain --untracked-files=normal)" ]]; then
tree_state=dirty
fi
allow_dirty=()
if [[ "$tree_state" != clean ]]; then
if [[ "${RENDEZVOUS_RELEASE_ALLOW_DIRTY:-0}" != 1 ]]; then
echo "A formal release must be built from a clean Git tree." >&2
exit 1
fi
allow_dirty=(--allow-dirty)
fi
if [[ -z "$output" ]]; then
output="$root/artifacts/release/$version"
fi
if [[ -e "$output" ]]; then
echo "Release output already exists; refusing to overwrite: $output" >&2
exit 1
fi
mkdir -p "$(dirname "$output")"
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-release.XXXXXX")"
cleanup() {
rm -rf "$work"
}
trap cleanup EXIT
mkdir -p "$work/pack-1" "$work/pack-2" "$work/publish-1" "$work/publish-2" "$output"
create_server_archive() {
local publish_directory="$1"
local destination="$2"
tar --sort=name \
--mtime="@$source_date_epoch" \
--owner=0 --group=0 --numeric-owner \
-C "$publish_directory" -cf - . \
| gzip -n >"$destination"
}
common=(
-p:ContinuousIntegrationBuild=true
-p:PackageVersion="$version"
-p:RepositoryCommit="$commit"
-p:SourceRevisionId="$commit"
)
cd "$root"
dotnet restore Rendezvous.slnx --locked-mode
python3 eng/release_artifacts.py policy --root "$root" >"$work/dependency-policy.json"
dotnet package list \
--project Rendezvous.slnx \
--vulnerable \
--include-transitive \
--no-restore \
--format json >"$work/nuget-vulnerabilities.json"
python3 eng/release_artifacts.py audit --input "$work/nuget-vulnerabilities.json"
dotnet format Rendezvous.slnx --verify-no-changes --no-restore
api_before="$(sha256sum docs/api/*.json)"
dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll \
"$work/FinalFactory.Rendezvous.Server.first.dll"
cp src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb \
"$work/FinalFactory.Rendezvous.Server.first.pdb"
dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
--configuration Release \
--no-build \
--no-restore \
--output "$work/publish-1" \
-p:UseAppHost=false \
-p:OpenApiGenerateDocuments=false \
"${common[@]}"
create_server_archive "$work/publish-1" "$work/FinalFactory.Rendezvous.Server.first.tar.gz"
if [[ "$tree_state" == clean ]]; then
git diff --exit-code -- docs/api
elif [[ "$api_before" != "$(sha256sum docs/api/*.json)" ]]; then
echo "Generated OpenAPI changed during the release build." >&2
exit 1
fi
dotnet test Rendezvous.slnx --configuration Release --no-build
for project in Client Contracts; do
dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
--configuration Release --no-build --output "$work/pack-1" "${common[@]}"
done
for package in "$work/pack-1"/*; do
python3 eng/release_artifacts.py normalize-package \
--package "$package" \
--source-date-epoch "$source_date_epoch" \
--version "$version"
done
# Rebuild from the locked graph and prove package byte reproducibility.
dotnet clean Rendezvous.slnx --configuration Release >/dev/null
dotnet restore Rendezvous.slnx --locked-mode
dotnet build Rendezvous.slnx --configuration Release --no-restore "${common[@]}"
for project in Client Contracts; do
python3 eng/release_artifacts.py source-link \
--file "src/FinalFactory.Rendezvous.$project/obj/Release/netstandard2.1/FinalFactory.Rendezvous.$project.sourcelink.json" \
--commit "$commit"
done
cmp --silent \
"$work/FinalFactory.Rendezvous.Server.first.dll" \
src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.dll || {
echo "Server assembly is not byte reproducible." >&2
exit 1
}
cmp --silent \
"$work/FinalFactory.Rendezvous.Server.first.pdb" \
src/FinalFactory.Rendezvous.Server/bin/Release/net10.0/FinalFactory.Rendezvous.Server.pdb || {
echo "Server portable PDB is not byte reproducible." >&2
exit 1
}
for project in Client Contracts; do
dotnet pack "src/FinalFactory.Rendezvous.$project/FinalFactory.Rendezvous.$project.csproj" \
--configuration Release --no-build --output "$work/pack-2" "${common[@]}"
done
for package in "$work/pack-2"/*; do
python3 eng/release_artifacts.py normalize-package \
--package "$package" \
--source-date-epoch "$source_date_epoch" \
--version "$version"
done
for package in "$work/pack-1"/*; do
cmp --silent "$package" "$work/pack-2/$(basename "$package")" || {
echo "Package is not byte reproducible: $(basename "$package")" >&2
exit 1
}
done
cp "$work/pack-1"/* "$output/"
python3 eng/release_artifacts.py consumer-config \
--local-source "$output" \
--output "$work/consumer.NuGet.config"
for consumer in spacegame unscouted; do
project="$root/tests/consumers/$consumer/$(find "$root/tests/consumers/$consumer" -maxdepth 1 -name '*.csproj' -printf '%f\n')"
packages="$work/consumer-packages-$consumer"
dotnet restore "$project" \
-p:RendezvousPackageVersion="$version" \
-p:RestoreLockedMode=false \
--packages "$packages" \
--configfile "$work/consumer.NuGet.config" \
--force-evaluate
dotnet build "$project" \
--configuration Release --no-restore \
-p:RendezvousPackageVersion="$version"
assets="$(dirname "$project")/obj/project.assets.json"
python3 eng/release_artifacts.py consumer --assets "$assets" --version "$version"
done
dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
--configuration Release \
--no-build \
--no-restore \
--output "$work/publish-2" \
-p:UseAppHost=false \
-p:OpenApiGenerateDocuments=false \
"${common[@]}"
server_archive="$output/FinalFactory.Rendezvous.Server.$version.linux-x64.tar.gz"
create_server_archive "$work/publish-2" "$server_archive"
cmp --silent "$work/FinalFactory.Rendezvous.Server.first.tar.gz" "$server_archive" || {
echo "Server archive is not byte reproducible." >&2
exit 1
}
cp CHANGELOG.md "$output/CHANGELOG.md"
python3 eng/release_artifacts.py sbom \
--root "$root" \
--version "$version" \
--commit "$commit" \
--source-date-epoch "$source_date_epoch" \
--server-deps "$work/publish-2/FinalFactory.Rendezvous.Server.deps.json" \
--output "$output/FinalFactory.Rendezvous.$version.spdx.json"
provenance=(
provenance
--root "$root"
--version "$version"
--commit "$commit"
--tree-state "$tree_state"
--source-date-epoch "$source_date_epoch"
--dotnet-sdk "$(dotnet --version)"
--build-tool "python=$(python3 --version 2>&1)"
--build-tool "tar=$(tar --version | sed -n '1p')"
--build-tool "gzip=$(gzip --version | sed -n '1p')"
--build-tool "jq=$(jq --version)"
--output "$output/release-provenance.json"
)
while IFS= read -r base; do
provenance+=(--base-digest "$base")
done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' Dockerfile)
while IFS= read -r base; do
provenance+=(--builder-base "$base")
done < <(sed -n 's/^FROM \([^ ]*\).*/\1/p' eng/release-builder.Dockerfile)
python3 eng/release_artifacts.py "${provenance[@]}"
(
cd "$output"
find . -maxdepth 1 -type f ! -name checksums.sha256 -printf '%f\n' \
| LC_ALL=C sort \
| xargs sha256sum >checksums.sha256
)
python3 eng/release_artifacts.py verify \
--root "$root" \
--release-dir "$output" \
--version "$version" \
"${allow_dirty[@]}"
echo "Release artifacts verified at $output"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base="${1:-origin/main}"
if ! git -C "$root" cat-file -e "$base^{commit}" 2>/dev/null; then
echo "Compatibility base does not exist; this is valid only for the initial version baseline: $base"
exit 0
fi
if [[ "$(git -C "$root" rev-parse "$base")" == "$(git -C "$root" rev-parse HEAD)" ]]; then
base="HEAD^"
fi
if ! git -C "$root" cat-file -e "$base:eng/Versions.props" 2>/dev/null; then
echo "Base has no release version manifest; accepting the initial compatibility baseline."
exit 0
fi
current_property() {
sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p" "$root/eng/Versions.props"
}
base_property() {
git -C "$root" show "$base:eng/Versions.props" \
| sed -n "s:.*<$1>\(.*\)</$1>.*:\1:p"
}
changed() {
git -C "$root" diff --name-only "$base"...HEAD -- "$@" | grep -q .
}
require_increase() {
local property="$1"
local description="$2"
shift 2
if changed "$@"; then
local before after
before="$(base_property "$property")"
after="$(current_property "$property")"
if [[ ! "$before" =~ ^[0-9]+$ || ! "$after" =~ ^[0-9]+$ || "$after" -le "$before" ]]; then
echo "$description changed without increasing $property ($before -> $after)." >&2
exit 1
fi
fi
}
require_increase RendezvousMajorVersion "Published .NET API snapshot" \
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/client-public-api.txt' \
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/contracts-public-api.txt'
require_increase HttpContractVersion "HTTP/OpenAPI contract evidence" \
'docs/api/*.json' \
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/*.json' \
':(exclude)tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/connection-ticket.json'
require_increase UdpContractVersion "UDP contract evidence" \
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/*.hex'
require_increase ConnectionTicketFormatVersion "Connection-ticket format evidence" \
'tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v*/connection-ticket.json'
echo "Compatibility changes are paired with the required version increase."
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RECORD="${1:-$ROOT/docs/evidence/production-readiness-v1.json}"
exec python3 "$ROOT/eng/check_production_readiness.py" "$RECORD"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tag="${1:-${GITHUB_REF_NAME:-}}"
version="$(sed -n 's:.*<RendezvousVersion>\(.*\)</RendezvousVersion>.*:\1:p' "$root/eng/Versions.props")"
if [[ "$tag" != "v$version" ]]; then
echo "Release tag $tag does not match central version v$version." >&2
exit 1
fi
if [[ -n "$(git -C "$root" status --porcelain --untracked-files=normal)" ]]; then
echo "Release tag checkout is not clean." >&2
exit 1
fi
if [[ "$(git -C "$root" tag --points-at HEAD --list "$tag")" != "$tag" ]]; then
echo "Release tag $tag does not point at the checked-out commit." >&2
exit 1
fi
if ! grep -Eq "^## $version - [0-9]{4}-[0-9]{2}-[0-9]{2}$" "$root/CHANGELOG.md"; then
echo "CHANGELOG.md must contain a dated heading for $version." >&2
exit 1
fi
if grep -Eq "^## $version - Unreleased$" "$root/CHANGELOG.md"; then
echo "Release $version is still marked Unreleased." >&2
exit 1
fi
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:?usage: finalize-release-candidate.sh VERSION RELEASE_DIRECTORY}"
release_dir="${2:?usage: finalize-release-candidate.sh VERSION RELEASE_DIRECTORY}"
container_sbom="$release_dir/FinalFactory.Rendezvous.Container.$version.spdx.json"
[[ -s "$container_sbom" ]] || {
echo "Container SBOM is missing or empty: $container_sbom" >&2
exit 1
}
(
cd "$release_dir"
find . -maxdepth 1 -type f ! -name checksums.sha256 -printf '%f\n' \
| LC_ALL=C sort \
| xargs sha256sum >checksums.sha256
)
python3 "$root/eng/release_artifacts.py" verify \
--root "$root" \
--release-dir "$release_dir" \
--version "$version" \
--phase publish-ready
echo "Finalized publish-ready release candidate $version"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:?usage: finalize-signing-ready-release.sh VERSION RELEASE_DIRECTORY}"
release_dir="${2:?usage: finalize-signing-ready-release.sh VERSION RELEASE_DIRECTORY}"
(
cd "$release_dir"
find . -maxdepth 1 -type f \
! -name checksums.sha256 \
! -name checksums.sha256.bundle \
-printf '%f\n' \
| LC_ALL=C sort \
| xargs sha256sum >checksums.sha256
)
python3 "$root/eng/release_artifacts.py" verify \
--root "$root" \
--release-dir "$release_dir" \
--version "$version" \
--phase signing-ready
echo "Finalized signing-ready release $version"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}"
GAME_ID="${RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID:-space-game}"
case "$GAME_ID" in
space-game)
KEY_ID="local-smoke-1"
SUBJECT="local-smoke-host"
;;
unscouted)
KEY_ID="local-smoke-unscouted-1"
SUBJECT="local-smoke-unscouted-host"
;;
*)
printf 'RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID must be space-game or unscouted.\n' >&2
exit 2
;;
esac
if (( $# != 0 )); then
printf 'This helper accepts no arguments; select only a provisioned local game through RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID.\n' >&2
exit 2
fi
command -v python3 >/dev/null || {
printf 'Missing required command: python3\n' >&2
exit 2
}
# This is deliberately a local-fixture tool, not a general credential issuer.
# Python reads the raw key from the protected file; key material never appears in
# a child process argument, environment value, temporary file, or command output.
python3 - "$LOCAL_KEY" "$GAME_ID" "$KEY_ID" "$SUBJECT" <<'PY'
import base64
import hashlib
import hmac
import json
import os
import secrets
import stat
import sys
import time
key_path = sys.argv[1]
game_id = sys.argv[2]
key_id = sys.argv[3]
subject = sys.argv[4]
try:
metadata = os.lstat(key_path)
except FileNotFoundError:
raise SystemExit(f"Local Compose smoke key does not exist: {key_path}")
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
raise SystemExit(f"Local Compose smoke key must be a regular non-symlink file: {key_path}")
parent_path = os.path.dirname(os.path.abspath(key_path))
parent = os.lstat(parent_path)
if stat.S_ISLNK(parent.st_mode) or not stat.S_ISDIR(parent.st_mode):
raise SystemExit(f"Local Compose secret directory must be a non-symlink directory: {parent_path}")
if parent.st_uid != os.geteuid() or parent.st_mode & 0o077:
raise SystemExit(f"Local Compose secret directory must be owned by this user with mode 0700: {parent_path}")
if metadata.st_uid != os.geteuid() or metadata.st_mode & 0o077 or metadata.st_nlink != 1:
raise SystemExit(f"Local Compose smoke key must be owned by this user, single-linked, and private to its owner: {key_path}")
with open(key_path, "rb") as key_file:
key = key_file.read(33)
if len(key) != 32:
raise SystemExit(f"Local Compose smoke key must be exactly 32 bytes: {key_path}")
now = int(time.time())
payload = {
"version": 1,
"issuer": "final-factory-rendezvous-smoke",
"audience": "rendezvous-service",
"subject": subject,
"kind": "dedicatedPublisher",
"gameId": game_id,
"environmentId": "smoke",
"regions": ["local"],
"permissions": [],
"issuedAtUnixSeconds": now,
"notBeforeUnixSeconds": now,
"expiresAtUnixSeconds": now + 600,
"nonce": secrets.token_hex(16),
}
def base64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
encoded = base64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
signed = f"rv1.{key_id}.{encoded}"
signature = base64url(hmac.new(key, signed.encode("ascii"), hashlib.sha256).digest())
print(f"{signed}.{signature}")
PY
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:?usage: publish-release.sh VERSION RELEASE_DIRECTORY}"
release_dir="${2:?usage: publish-release.sh VERSION RELEASE_DIRECTORY}"
api="${RENDEZVOUS_GITEA_API:-https://git.finalfactory.de/api/v1}"
registry="${RENDEZVOUS_CONTAINER_REGISTRY:-git.finalfactory.de}"
image="$registry/heikyu/rendezvous:$version"
token="${RENDEZVOUS_RELEASE_TOKEN:?RENDEZVOUS_RELEASE_TOKEN is required}"
username="${RENDEZVOUS_RELEASE_USERNAME:?RENDEZVOUS_RELEASE_USERNAME is required}"
release_builder="${RENDEZVOUS_RELEASE_BUILDER:?RENDEZVOUS_RELEASE_BUILDER is required}"
docker_config="$(mktemp -d)"
curl_config="$(mktemp)"
release_request=""
release_response=""
cleanup() {
[[ -z "$release_request" ]] || rm -f "$release_request"
[[ -z "$release_response" ]] || rm -f "$release_response"
rm -f "$curl_config"
rm -rf "$docker_config"
}
trap cleanup EXIT
chmod 0700 "$docker_config"
chmod 0600 "$curl_config"
printf 'header = "Authorization: token %s"\n' "$token" >"$curl_config"
export DOCKER_CONFIG="$docker_config"
for command in cosign curl docker dotnet jq; do
command -v "$command" >/dev/null || {
echo "Required publication command is unavailable: $command" >&2
exit 1
}
done
run_release_builder() {
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$root:/source:ro" \
--volume "$release_dir:$release_dir" \
--workdir /source \
"$release_builder" "$@"
}
"$root/scripts/check-release-tag.sh" "v$version"
"$root/scripts/verify-release.sh" "$version" "$release_dir" publish-ready
require_absent() {
local description="$1"
local url="$2"
local status
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
--config "$curl_config" "$url")"
if [[ "$status" != 404 ]]; then
echo "$description must not exist before publication (HTTP $status)." >&2
exit 1
fi
}
# Gitea package versions are immutable. Require all destinations to be empty
# before the first write so a tag can never become a silent partial rerun.
require_absent "Client package $version" \
"$api/packages/HeiKyu/nuget/FinalFactory.Rendezvous.Client/$version"
require_absent "Contracts package $version" \
"$api/packages/HeiKyu/nuget/FinalFactory.Rendezvous.Contracts/$version"
require_absent "Container $version" \
"$api/packages/HeiKyu/container/rendezvous/$version"
require_absent "Release v$version" \
"$api/repos/HeiKyu/Rendezvous/releases/tags/v$version"
feed="https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json"
for package in \
"$release_dir/FinalFactory.Rendezvous.Contracts.$version.nupkg" \
"$release_dir/FinalFactory.Rendezvous.Client.$version.nupkg"; do
dotnet nuget push "$package" \
--source "$feed" \
--api-key "$token" \
--timeout 300
done
printf '%s' "$token" | docker login "$registry" --username "$username" --password-stdin
expected_image_id="$(jq -er '.containerImageId' "$release_dir/release-provenance.json")"
current_image_id="$(docker image inspect --format '{{.Id}}' "$image")"
if [[ "$current_image_id" != "$expected_image_id" ]]; then
echo "Local release tag changed after staging ($expected_image_id -> $current_image_id)." >&2
exit 1
fi
docker push "$image"
digest_ref="$(docker inspect --format '{{index .RepoDigests 0}}' "$image")"
if [[ ! "$digest_ref" =~ ^git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}$ ]]; then
echo "Registry did not return an immutable Rendezvous image digest: $digest_ref" >&2
exit 1
fi
run_release_builder python3 eng/release_artifacts.py record-container-digest \
--release-dir "$release_dir" \
--digest "$digest_ref"
cosign public-key --key env://COSIGN_PRIVATE_KEY >"$release_dir/cosign.pub"
run_release_builder ./scripts/finalize-signing-ready-release.sh \
"$version" "$release_dir"
cosign sign --yes --key env://COSIGN_PRIVATE_KEY "$digest_ref"
cosign attest --yes \
--key env://COSIGN_PRIVATE_KEY \
--type https://finalfactory.de/rendezvous/release-provenance/v1 \
--predicate "$release_dir/release-provenance.json" \
"$digest_ref"
cosign sign-blob --yes \
--key env://COSIGN_PRIVATE_KEY \
--bundle "$release_dir/checksums.sha256.bundle" \
"$release_dir/checksums.sha256"
"$root/scripts/verify-release.sh" "$version" "$release_dir" published
cosign verify --key "$release_dir/cosign.pub" "$digest_ref" >/dev/null
cosign verify-attestation \
--key "$release_dir/cosign.pub" \
--type https://finalfactory.de/rendezvous/release-provenance/v1 \
"$digest_ref" >/dev/null
cosign verify-blob \
--key "$release_dir/cosign.pub" \
--bundle "$release_dir/checksums.sha256.bundle" \
"$release_dir/checksums.sha256" >/dev/null
release_request="$(mktemp)"
release_response="$(mktemp)"
prerelease=false
if [[ "$version" == *-* ]]; then
prerelease=true
fi
jq -n \
--arg tag "v$version" \
--arg commit "${GITHUB_SHA:?GITHUB_SHA is required}" \
--arg digest "$digest_ref" \
--argjson prerelease "$prerelease" \
--rawfile changelog "$release_dir/CHANGELOG.md" \
'{tag_name:$tag,target_commitish:$commit,name:("Rendezvous " + $tag),body:($changelog + "\n\n## Immutable container\n\n`" + $digest + "`\n"),draft:false,prerelease:$prerelease}' \
>"$release_request"
curl --fail --silent --show-error \
--request POST \
--config "$curl_config" \
--header 'Content-Type: application/json' \
--data-binary "@$release_request" \
"$api/repos/HeiKyu/Rendezvous/releases" >"$release_response"
release_id="$(jq -er '.id' "$release_response")"
for artifact in "$release_dir"/*; do
curl --fail --silent --show-error \
--request POST \
--config "$curl_config" \
--form "attachment=@$artifact" \
"$api/repos/HeiKyu/Rendezvous/releases/$release_id/assets?name=$(basename "$artifact")" \
>/dev/null
done
echo "Published immutable release v$version with container $digest_ref"
+58
View File
@@ -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"
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT="$ROOT/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj"
ROLE="${RENDEZVOUS_CANARY_ROLE:-}"
TOPOLOGY="${RENDEZVOUS_CANARY_TOPOLOGY:-}"
ADDRESS_FAMILY="${RENDEZVOUS_CANARY_ADDRESS_FAMILY:-ipv4}"
SERVICE_URL="${RENDEZVOUS_CANARY_HTTP_URL:-}"
MEDIATOR="${RENDEZVOUS_CANARY_UDP_ENDPOINT:-}"
GAME_ID="${RENDEZVOUS_CANARY_GAME_ID:-space-game}"
ENVIRONMENT_ID="${RENDEZVOUS_CANARY_ENVIRONMENT_ID:-production-canary}"
REGION="${RENDEZVOUS_CANARY_REGION:-production-canary}"
PROTOCOL_VERSION="${RENDEZVOUS_CANARY_PROTOCOL_VERSION:-1}"
TIMEOUT_SECONDS="${RENDEZVOUS_CANARY_TIMEOUT_SECONDS:-60}"
RUN_SECONDS="${RENDEZVOUS_CANARY_RUN_SECONDS:-900}"
OUTPUT="${RENDEZVOUS_CANARY_OUTPUT:-$ROOT/artifacts/canary/${ROLE:-unknown}-${TOPOLOGY:-unknown}.json}"
COORDINATION_FILE="${RENDEZVOUS_CANARY_COORDINATION_FILE:-}"
LISTING_ID="${RENDEZVOUS_CANARY_LISTING_ID:-}"
REQUIRE_CLEAN="${RENDEZVOUS_CANARY_REQUIRE_CLEAN:-true}"
KEEP_RAW="${RENDEZVOUS_CANARY_KEEP_RAW:-false}"
UUID_PATTERN='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
usage() {
printf '%s\n' \
'Set RENDEZVOUS_CANARY_ROLE to host, client-success, or client-expected-failure.' \
'Also set RENDEZVOUS_CANARY_TOPOLOGY, RENDEZVOUS_CANARY_HTTP_URL, and' \
'RENDEZVOUS_CANARY_UDP_ENDPOINT. See docs/operations/production-readiness.md.' >&2
exit 2
}
for command in date dotnet git jq mktemp tail; do
command -v "$command" >/dev/null || {
printf 'Missing required command: %s\n' "$command" >&2
exit 2
}
done
case "$ROLE" in
host|client-success|client-expected-failure) ;;
*) usage ;;
esac
case "$TOPOLOGY" in
same-lan|home-nat|firewall-blocked-udp|restrictive-cgnat|ipv6-direct) ;;
*) usage ;;
esac
case "$ADDRESS_FAMILY" in
ipv4|ipv6) ;;
*) printf 'RENDEZVOUS_CANARY_ADDRESS_FAMILY must be ipv4 or ipv6.\n' >&2; exit 2 ;;
esac
if [[ "$TOPOLOGY" == ipv6-direct && "$ADDRESS_FAMILY" != ipv6 ]]; then
printf 'The ipv6-direct topology requires RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv6.\n' >&2
exit 2
fi
if [[ "$TOPOLOGY" =~ ^(firewall-blocked-udp|restrictive-cgnat)$ \
&& "$ROLE" == client-success ]]; then
printf 'Failure topologies must use the client-expected-failure role.\n' >&2
exit 2
fi
if [[ -z "$SERVICE_URL" || -z "$MEDIATOR" ]]; then
usage
fi
if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] \
|| (( TIMEOUT_SECONDS < 1 || TIMEOUT_SECONDS > 300 )); then
printf 'RENDEZVOUS_CANARY_TIMEOUT_SECONDS must be an integer from 1 through 300.\n' >&2
exit 2
fi
if [[ ! "$RUN_SECONDS" =~ ^[0-9]+$ ]] \
|| (( RUN_SECONDS < 60 || RUN_SECONDS > 3600 )); then
printf 'RENDEZVOUS_CANARY_RUN_SECONDS must be an integer from 60 through 3600.\n' >&2
exit 2
fi
if [[ ! "$PROTOCOL_VERSION" =~ ^[0-9]+$ ]] || (( PROTOCOL_VERSION < 1 )); then
printf 'RENDEZVOUS_CANARY_PROTOCOL_VERSION must be a positive integer.\n' >&2
exit 2
fi
if [[ "$REQUIRE_CLEAN" != true && "$REQUIRE_CLEAN" != false ]]; then
printf 'RENDEZVOUS_CANARY_REQUIRE_CLEAN must be true or false.\n' >&2
exit 2
fi
if [[ "$KEEP_RAW" != true && "$KEEP_RAW" != false ]]; then
printf 'RENDEZVOUS_CANARY_KEEP_RAW must be true or false.\n' >&2
exit 2
fi
cd "$ROOT"
commit="$(git rev-parse HEAD)"
tree_state=clean
if [[ -n "$(git status --porcelain)" ]]; then
tree_state=dirty
fi
if [[ "$REQUIRE_CLEAN" == true && "$tree_state" != clean ]]; then
printf 'Formal canary evidence requires a clean source tree.\n' >&2
exit 2
fi
if [[ "$ROLE" == host ]]; then
if [[ -z "$COORDINATION_FILE" ]]; then
printf 'The host role requires RENDEZVOUS_CANARY_COORDINATION_FILE.\n' >&2
exit 2
fi
if [[ -e "$COORDINATION_FILE" ]]; then
printf 'The host coordination file already exists; remove it explicitly before a new canary.\n' >&2
exit 2
fi
if [[ -z "${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}" ]]; then
printf 'The host role requires RENDEZVOUS_PUBLISHER_CREDENTIAL.\n' >&2
exit 2
fi
else
if [[ ! "$LISTING_ID" =~ $UUID_PATTERN ]]; then
printf 'A client role requires a UUID in RENDEZVOUS_CANARY_LISTING_ID.\n' >&2
exit 2
fi
fi
umask 077
raw_dir="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-canary.XXXXXXXX")"
raw_log="$raw_dir/events.jsonl"
run_succeeded=false
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 [[ "$run_succeeded" == true && "$KEEP_RAW" == false ]]; then
rm -rf "$raw_dir"
else
printf 'Private raw canary events retained at %s\n' "$raw_dir" >&2
fi
return "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
common_arguments=(
--service "$SERVICE_URL"
--mediator "$MEDIATOR"
--game "$GAME_ID"
--environment "$ENVIRONMENT_ID"
--region "$REGION"
--protocol "$PROTOCOL_VERSION"
--script
--json
--timeout-seconds "$TIMEOUT_SECONDS"
)
exit_code=0
if [[ "$ROLE" == host ]]; then
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
host "${common_arguments[@]}" --exit-after-echo --run-seconds "$RUN_SECONDS" \
>"$raw_log" 2>&1 &
host_pid="$!"
ready=false
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
if jq -e 'select(.event == "host.ready" and .status == "ready")' "$raw_log" \
>/dev/null 2>&1; then
ready=true
break
fi
if ! kill -0 "$host_pid" 2>/dev/null; then
break
fi
sleep 0.25
done
if [[ "$ready" != true ]]; then
printf 'The canary host did not become ready within the bounded startup window.\n' >&2
kill -TERM "$host_pid" 2>/dev/null || true
wait "$host_pid" 2>/dev/null || true
exit 1
fi
observed_listing="$(jq -r 'select(.event == "host.registered") | .listingId' "$raw_log" | tail -n 1)"
if [[ ! "$observed_listing" =~ $UUID_PATTERN ]]; then
printf 'The canary host did not produce a valid coordination identifier.\n' >&2
kill -TERM "$host_pid" 2>/dev/null || true
wait "$host_pid" 2>/dev/null || true
exit 1
fi
coordination_parent="$(dirname "$COORDINATION_FILE")"
mkdir -p "$coordination_parent"
coordination_temp="$(mktemp "$COORDINATION_FILE.tmp.XXXXXXXX")"
printf '%s\n' "$observed_listing" >"$coordination_temp"
mv "$coordination_temp" "$COORDINATION_FILE"
printf 'Host ready; securely transfer the private coordination file to the client operator.\n'
set +e
wait "$host_pid"
exit_code="$?"
set -e
elif [[ "$ROLE" == client-success ]]; then
set +e
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
exit_code="$?"
set -e
else
set +e
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
exit_code="$?"
set -e
fi
checks='{}'
if [[ "$ROLE" == host ]]; then
[[ "$exit_code" -eq 0 ]]
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "host.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$raw_log" >/dev/null
checks='{"authenticatedDirectTraffic":true,"deregistered":true}'
elif [[ "$ROLE" == client-success ]]; then
[[ "$exit_code" -eq 0 ]]
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.connected" and .status == "connected" and .addressFamily == $family)' "$raw_log" >/dev/null
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$raw_log" >/dev/null
checks='{"authenticatedDirectTraffic":true,"typedOutcomeReported":true}'
else
[[ "$exit_code" -eq 12 ]]
jq -e 'select((.event == "join.traversal" or .event == "join.authorization") and .status == "failed" and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
jq -e 'select(.event == "join.fallback" and (.status == "available" or .status == "unavailable") and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
checks='{"boundedTypedFailure":true,"fallbackPolicyReported":true}'
fi
mkdir -p "$(dirname "$OUTPUT")"
raw_retention=deleted-after-success
if [[ "$KEEP_RAW" == true ]]; then
raw_retention=retained-private-on-request
fi
jq -n \
--arg commit "$commit" \
--arg treeState "$tree_state" \
--arg timestampUtc "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg role "$ROLE" \
--arg topology "$TOPOLOGY" \
--arg addressFamily "$ADDRESS_FAMILY" \
--arg rawEvents "$raw_retention" \
--argjson checks "$checks" \
'{schemaVersion:1,kind:"rendezvous-real-network-canary",commit:$commit,treeState:$treeState,timestampUtc:$timestampUtc,role:$role,topology:$topology,addressFamily:$addressFamily,result:"pass",checks:$checks,dataRetention:{rawEvents:$rawEvents,identifiers:"not-in-summary",networkEndpoints:"not-in-summary"}}' \
>"$OUTPUT"
run_succeeded=true
printf 'Real-network canary passed; sanitized evidence: %s\n' "$OUTPUT"
+123
View File
@@ -0,0 +1,123 @@
#!/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 dotnet jq mktemp tail; 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
local_credential() {
if [[ "$GAME_ID" != space-game || "$ENVIRONMENT_ID" != smoke \
|| "$REGION" != local || "$PROTOCOL_VERSION" != 1 ]]; then
printf 'The local credential helper supports only space-game/smoke/local protocol 1. Supply RENDEZVOUS_PUBLISHER_CREDENTIAL for any other scope.\n' >&2
exit 2
fi
RENDEZVOUS_SMOKE_LOCAL_KEY="$LOCAL_KEY" \
"$ROOT/scripts/mint-local-publisher-credential.sh"
}
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'
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}"
release_dir="${2:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}"
manifest="$root/eng/consumer-revisions.json"
work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-consumers.XXXXXX")"
cleanup() {
rm -rf "$work"
}
trap cleanup EXIT
for command in dotnet git jq python3; do
command -v "$command" >/dev/null || {
echo "Required consumer verification command is unavailable: $command" >&2
exit 1
}
done
python3 "$root/eng/release_artifacts.py" consumer-config \
--local-source "$release_dir" \
--output "$work/NuGet.config"
count="$(jq '.consumers | length' "$manifest")"
for ((index = 0; index < count; index++)); do
name="$(jq -r ".consumers[$index].name" "$manifest")"
repository="$(jq -r ".consumers[$index].repository" "$manifest")"
revision="$(jq -r ".consumers[$index].revision" "$manifest")"
project_relative="$(jq -r ".consumers[$index].project" "$manifest")"
checkout="$work/$name"
git -c init.defaultBranch=main init --quiet "$checkout"
git -C "$checkout" remote add origin "$repository"
git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
GIT_LFS_SKIP_SMUDGE=1 git -C "$checkout" checkout --quiet --detach FETCH_HEAD
[[ "$(git -C "$checkout" rev-parse HEAD)" == "$revision" ]] || {
echo "$name did not resolve the pinned consumer revision." >&2
exit 1
}
project="$checkout/$project_relative"
[[ -f "$project" ]] || {
echo "$name consumer project does not exist at $project_relative." >&2
exit 1
}
targets="$work/$name.Rendezvous.Consumer.targets"
cat >"$targets" <<EOF
<Project>
<ItemGroup Condition="'\$(MSBuildProjectFullPath)' == '$project'">
<PackageReference Remove="FinalFactory.Rendezvous.Client" />
<PackageReference Remove="FinalFactory.Rendezvous.Contracts" />
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="[$version]" />
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="[$version]" />
</ItemGroup>
</Project>
EOF
packages="$work/packages-$name"
dotnet restore "$project" \
-p:CustomAfterMicrosoftCommonTargets="$targets" \
-p:RestorePackagesWithLockFile=false \
-p:RestoreLockedMode=false \
--packages "$packages" \
--configfile "$work/NuGet.config" \
--force-evaluate
assets=""
while IFS= read -r candidate_assets; do
if grep -Fq "FinalFactory.Rendezvous.Client/$version" "$candidate_assets"; then
assets="$candidate_assets"
break
fi
done < <(find "$checkout" -path '*/obj/project.assets.json' -type f -print)
[[ -n "$assets" ]] || {
echo "$name restore did not produce assets for the injected Rendezvous references." >&2
exit 1
}
python3 "$root/eng/release_artifacts.py" consumer \
--assets "$assets" \
--version "$version"
echo "Verified $name at $revision can pin and restore Rendezvous $version."
done
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:?usage: verify-release.sh VERSION [RELEASE_DIRECTORY] [PHASE]}"
release_dir="${2:-$root/artifacts/release/$version}"
phase="${3:-build}"
python3 "$root/eng/release_artifacts.py" verify \
--root "$root" \
--release-dir "$release_dir" \
--version "$version" \
--phase "$phase"
@@ -0,0 +1,183 @@
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousConnectionOutcomeSource
{
RendezvousService = 1,
LocalTraversal = 2,
RemoteHost = 3,
Caller = 4,
Lifecycle = 5,
}
public enum RendezvousConnectionFailureCategory
{
None = 0,
Directory = 1,
Compatibility = 2,
Authorization = 3,
Capacity = 4,
HostPresence = 5,
Service = 6,
Mediation = 7,
NatTraversal = 8,
DirectConnection = 9,
Lifecycle = 10,
}
public enum RendezvousConnectionPhase
{
Directory = 1,
Authorization = 2,
Mediation = 3,
NatTraversal = 4,
DirectConnection = 5,
Complete = 6,
}
public sealed class RendezvousConnectionOutcome
{
private readonly NetworkEndpoint? _dedicatedFallback;
private RendezvousConnectionOutcome(
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
TimeSpan elapsed,
RendezvousErrorCode? serviceError,
NetworkEndpoint? dedicatedFallback,
NetPeer? peer)
{
if (elapsed < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(elapsed));
}
if (dedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
{
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
}
Kind = kind;
Source = source;
Category = category;
Phase = phase;
Elapsed = elapsed;
ServiceError = serviceError;
_dedicatedFallback = RendezvousEndpoint.Copy(dedicatedFallback);
Peer = peer;
}
public ConnectionOutcomeKind Kind { get; }
public RendezvousConnectionOutcomeSource Source { get; }
public RendezvousConnectionFailureCategory Category { get; }
public RendezvousConnectionPhase Phase { get; }
public TimeSpan Elapsed { get; }
public RendezvousErrorCode? ServiceError { get; }
public NetworkEndpoint? DedicatedFallback => RendezvousEndpoint.Copy(_dedicatedFallback);
public NetPeer? Peer { get; }
public bool IsSuccess => Kind == ConnectionOutcomeKind.Connected;
public bool HasDedicatedFallback => _dedicatedFallback is not null;
public static RendezvousConnectionOutcome FromServiceError(
RendezvousErrorCode error,
TimeSpan elapsed,
NetworkEndpoint? dedicatedFallback = null)
{
if (error == RendezvousErrorCode.None)
{
throw new ArgumentException("A service failure outcome requires an error.", nameof(error));
}
(ConnectionOutcomeKind kind, RendezvousConnectionFailureCategory category, RendezvousConnectionPhase phase) =
error switch
{
RendezvousErrorCode.NotFound => (
ConnectionOutcomeKind.DirectoryNotFound,
RendezvousConnectionFailureCategory.Directory,
RendezvousConnectionPhase.Directory),
RendezvousErrorCode.Expired => (
ConnectionOutcomeKind.AttemptExpired,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization),
RendezvousErrorCode.IncompatibleProtocol => (
ConnectionOutcomeKind.IncompatibleProtocol,
RendezvousConnectionFailureCategory.Compatibility,
RendezvousConnectionPhase.Directory),
RendezvousErrorCode.AuthenticationRequired
or RendezvousErrorCode.Forbidden
or RendezvousErrorCode.ReplayRejected => (
ConnectionOutcomeKind.Unauthorized,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization),
RendezvousErrorCode.RateLimited
or RendezvousErrorCode.CapacityExceeded => (
ConnectionOutcomeKind.RateLimited,
RendezvousConnectionFailureCategory.Capacity,
RendezvousConnectionPhase.Authorization),
RendezvousErrorCode.StaleHost => (
ConnectionOutcomeKind.NoHostPresence,
RendezvousConnectionFailureCategory.HostPresence,
RendezvousConnectionPhase.Mediation),
RendezvousErrorCode.ServiceUnavailable => (
ConnectionOutcomeKind.ServiceUnavailable,
RendezvousConnectionFailureCategory.Service,
RendezvousConnectionPhase.Authorization),
_ => (
ConnectionOutcomeKind.ServiceRejected,
RendezvousConnectionFailureCategory.Service,
RendezvousConnectionPhase.Authorization),
};
return new(
kind,
RendezvousConnectionOutcomeSource.RendezvousService,
category,
phase,
elapsed,
error,
dedicatedFallback,
null);
}
public static ConnectionElapsedBucket BucketElapsed(TimeSpan elapsed)
{
if (elapsed < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(elapsed));
}
return elapsed.TotalSeconds switch
{
< 1 => ConnectionElapsedBucket.UnderOneSecond,
< 5 => ConnectionElapsedBucket.OneToFiveSeconds,
< 15 => ConnectionElapsedBucket.FiveToFifteenSeconds,
< 30 => ConnectionElapsedBucket.FifteenToThirtySeconds,
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
};
}
public override string ToString() =>
$"[RendezvousConnectionOutcome {Kind}; {Source}; credentials redacted]";
internal static RendezvousConnectionOutcome Create(
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
TimeSpan elapsed,
NetworkEndpoint? dedicatedFallback = null,
NetPeer? peer = null) => new(
kind,
source,
category,
phase,
elapsed,
null,
dedicatedFallback,
peer);
}
@@ -0,0 +1,35 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousConnectionStartResult
{
internal RendezvousConnectionStartResult(
CreateJoinAttemptResponse? attempt,
RendezvousConnectionOutcome? outcome)
{
if ((attempt is null) == (outcome is null))
{
throw new ArgumentException(
"A connection start result requires exactly one attempt or terminal outcome.");
}
Attempt = attempt;
Outcome = outcome;
}
public CreateJoinAttemptResponse? Attempt { get; }
public RendezvousConnectionOutcome? Outcome { get; }
public bool IsReadyForTraversal => Attempt is not null;
public bool IsCompleted => Outcome is not null;
public static RendezvousConnectionStartResult ReadyForTraversal(
CreateJoinAttemptResponse attempt) => new(
attempt ?? throw new ArgumentNullException(nameof(attempt)),
null);
public static RendezvousConnectionStartResult Completed(
RendezvousConnectionOutcome outcome) => new(
null,
outcome ?? throw new ArgumentNullException(nameof(outcome)));
}
@@ -0,0 +1,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,14 @@
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
<IsPackable>true</IsPackable>
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
<PackageTags>final-factory;multiplayer;nat;godot;litenetlib</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" />
<None Update="README.md" Pack="true" PackagePath="\" />
<None Include="../../CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup>
</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,202 @@
# 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();
gameplayNetManager.ChannelsCount = 3; // example: configure the game protocol first
if (!gameplayNetManager.Start(0))
{
throw new InvalidOperationException("The gameplay UDP socket could not start.");
}
```
LiteNetLib defaults to one QoS channel. Set `ChannelsCount` before `Start` when
the game protocol uses more than one; both game processes must agree. Rendezvous
does not reserve or reinterpret any gameplay channel.
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,230 @@
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);
IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
BrowseSessionsRequest request,
string streamCursor,
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,290 @@
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 async Task<RendezvousClientResult<HttpResponseMessage>> OpenStreamAsync(
Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using CancellationTokenSource requestTimeout =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
requestTimeout.CancelAfter(_options.RequestTimeout);
CancellationToken requestCancellation = requestTimeout.Token;
using HttpRequestMessage request = requestFactory();
HttpResponseMessage? response = null;
try
{
response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
requestCancellation).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
HttpResponseMessage ownedResponse = response;
response = null;
return RendezvousClientResult.Success(ownedResponse);
}
ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
return RendezvousClientResult.Failure<HttpResponseMessage>(
error.Code,
error.Message,
retryAfter);
}
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
{
return RendezvousClientResult.Failure<HttpResponseMessage>(
RendezvousErrorCode.ServiceUnavailable,
"The Rendezvous event stream could not be opened.");
}
finally
{
response?.Dispose();
}
}
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,157 @@
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,
RegionId = request.RegionId,
ProtocolVersion = request.ProtocolVersion,
Visibility = request.Visibility,
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,373 @@
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
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 ? $"&regionId={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);
}
public async IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
BrowseSessionsRequest request,
string streamCursor,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (string.IsNullOrWhiteSpace(streamCursor)
|| !ContractValidation.IsCursorValid(streamCursor))
{
throw new ArgumentException("A valid snapshot stream cursor is required.", nameof(streamCursor));
}
string query = $"v1/sessions/stream?contractVersion={request.ContractVersion}"
+ $"&gameId={Escape(request.GameId.Value)}"
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
+ $"&protocolVersion={request.ProtocolVersion}"
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
+ (request.RegionId.HasValue ? $"&regionId={Escape(request.RegionId.Value.Value)}" : string.Empty);
RendezvousClientResult<HttpResponseMessage> opened = await _transport.OpenStreamAsync(
() =>
{
HttpRequestMessage message = new(HttpMethod.Get, query);
message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
message.Headers.TryAddWithoutValidation("Last-Event-ID", streamCursor);
return message;
},
cancellationToken).ConfigureAwait(false);
if (!opened.IsSuccess || opened.Value is null)
{
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
opened.Error,
opened.Message,
opened.RetryAfterSeconds);
yield break;
}
using HttpResponseMessage response = opened.Value;
if (!string.Equals(
response.Content.Headers.ContentType?.MediaType,
"text/event-stream",
StringComparison.OrdinalIgnoreCase))
{
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
RendezvousErrorCode.InternalError,
"The service returned an invalid event-stream content type.");
yield break;
}
using Stream source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
using SseLineReader reader = new(source);
while (true)
{
SseReadResult? read = null;
RendezvousClientResult<SessionStreamEvent>? readFailure = null;
bool cancelled = false;
try
{
read = await ReadEventAsync(reader, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
cancelled = true;
}
catch (Exception exception) when (exception is IOException or JsonException or InvalidDataException)
{
readFailure = RendezvousClientResult.Failure<SessionStreamEvent>(
RendezvousErrorCode.InternalError,
"The service returned an invalid or oversized event stream.");
}
if (cancelled)
{
yield break;
}
if (readFailure is not null)
{
yield return readFailure;
yield break;
}
if (read!.EndOfStream)
{
yield break;
}
yield return read.Result!;
if (!read.Result!.IsSuccess)
{
yield break;
}
}
}
private static async Task<SseReadResult> ReadEventAsync(
SseLineReader reader,
CancellationToken cancellationToken)
{
string? eventName = null;
string? id = null;
string? data = null;
int bytes = 0;
while (true)
{
string? line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
return eventName is null && id is null && data is null
? SseReadResult.End
: throw new InvalidDataException("The final SSE event was incomplete.");
}
bytes += Encoding.UTF8.GetByteCount(line) + 1;
if (bytes > ContractLimits.SessionStreamEventMaxBytes)
{
throw new InvalidDataException("The SSE event exceeded the contract limit.");
}
if (line.Length == 0)
{
break;
}
if (line.StartsWith("event: ", StringComparison.Ordinal))
{
eventName = line[7..];
}
else if (line.StartsWith("id: ", StringComparison.Ordinal))
{
id = line[4..];
}
else if (line.StartsWith("data: ", StringComparison.Ordinal))
{
data = line[6..];
}
}
SessionStreamEvent? item = data is null
? null
: JsonSerializer.Deserialize<SessionStreamEvent>(data, ContractJson.Options);
if (item is null
|| !string.Equals(item.Cursor, id, StringComparison.Ordinal)
|| !string.Equals(eventName, EventName(item.Kind), StringComparison.Ordinal)
|| !IsValidShape(item))
{
return new(false, RendezvousClientResult.Failure<SessionStreamEvent>(
RendezvousErrorCode.InternalError,
"The service returned an invalid event envelope."));
}
return new(false, RendezvousClientResult.Success(item));
}
private static string EventName(SessionStreamEventKind kind) => kind switch
{
SessionStreamEventKind.SessionUpsert => "session_upsert",
SessionStreamEventKind.SessionRemove => "session_remove",
SessionStreamEventKind.Reset => "reset",
SessionStreamEventKind.Keepalive => "keepalive",
_ => string.Empty,
};
private static bool IsValidShape(SessionStreamEvent item) =>
item.ContractVersion == ContractLimits.ContractVersion
&& !string.IsNullOrWhiteSpace(item.Cursor)
&& ContractValidation.IsCursorValid(item.Cursor)
&& (item.Kind == SessionStreamEventKind.SessionUpsert
&& item.Session is not null
&& IsValidListing(item.Session)
&& item.ListingId is null
|| item.Kind == SessionStreamEventKind.SessionRemove
&& item.Session is null
&& item.ListingId.HasValue
&& item.ListingId.Value.Value != Guid.Empty
|| item.Kind is SessionStreamEventKind.Reset or SessionStreamEventKind.Keepalive
&& item.Session is null
&& item.ListingId is null);
private static bool IsValidListing(SessionListing listing) =>
listing.ContractVersion == ContractLimits.ContractVersion
&& listing.ListingId.Value != Guid.Empty
&& !string.IsNullOrWhiteSpace(listing.GameId.Value)
&& !string.IsNullOrWhiteSpace(listing.EnvironmentId.Value)
&& !string.IsNullOrWhiteSpace(listing.RegionId.Value)
&& listing.ProtocolVersion != 0
&& ContractValidation.IsBuildVersionValid(listing.BuildVersion)
&& ContractValidation.IsDisplayNameValid(listing.DisplayName)
&& listing.Visibility == ListingVisibility.Public
&& Enum.IsDefined(typeof(PublisherTrustMode), listing.PublisherTrustMode)
&& ContractValidation.IsCapacityValid(listing.Capacity)
&& ContractValidation.IsMetadataValid(listing.Metadata)
&& (listing.DedicatedFallback is null
|| ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback));
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
private sealed class SseReadResult
{
public SseReadResult(
bool endOfStream,
RendezvousClientResult<SessionStreamEvent>? result)
{
EndOfStream = endOfStream;
Result = result;
}
public bool EndOfStream { get; }
public RendezvousClientResult<SessionStreamEvent>? Result { get; }
public static SseReadResult End { get; } = new(true, null);
}
private sealed class SseLineReader(Stream source) : IDisposable
{
private static readonly UTF8Encoding Utf8 = new(false, true);
private readonly byte[] _buffer = new byte[4096];
private readonly MemoryStream _line = new();
private int _offset;
private int _count;
public async Task<string?> ReadLineAsync(CancellationToken cancellationToken)
{
while (true)
{
if (_offset >= _count)
{
_count = await source.ReadAsync(
_buffer.AsMemory(),
cancellationToken).ConfigureAwait(false);
_offset = 0;
if (_count == 0)
{
if (_line.Length == 0)
{
return null;
}
return TakeLine();
}
}
byte value = _buffer[_offset++];
if (value == (byte)'\n')
{
return TakeLine();
}
if (_line.Length >= ContractLimits.SessionStreamEventMaxBytes)
{
throw new InvalidDataException("An SSE line exceeded the contract limit.");
}
_line.WriteByte(value);
}
}
public void Dispose() => _line.Dispose();
private string TakeLine()
{
byte[] bytes = _line.ToArray();
_line.SetLength(0);
int length = bytes.Length > 0 && bytes[^1] == (byte)'\r'
? bytes.Length - 1
: bytes.Length;
return Utf8.GetString(bytes, 0, length);
}
}
}
@@ -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);
}
@@ -4,7 +4,7 @@
".NETStandard,Version=v2.1": {
"LiteNetLib": {
"type": "Direct",
"requested": "[2.1.4, )",
"requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
@@ -49,6 +49,27 @@ public enum ConnectionOutcomeKind
HostRejected = 7,
TransportFailed = 8,
FallbackOffered = 9,
DirectoryNotFound = 10,
AttemptExpired = 11,
Unauthorized = 12,
RateLimited = 13,
NoHostPresence = 14,
ServiceUnavailable = 15,
MediatorUnavailable = 16,
PunchTimedOut = 17,
DirectConnectTimedOut = 18,
TransportError = 19,
ManagerStopped = 20,
Disposed = 21,
}
public enum ConnectionElapsedBucket
{
UnderOneSecond = 1,
OneToFiveSeconds = 2,
FiveToFifteenSeconds = 3,
FifteenToThirtySeconds = 4,
ThirtySecondsOrMore = 5,
}
public enum UdpPresenceMessageType : byte
@@ -5,6 +5,7 @@ public static class ContractLimits
public const int ContractVersion = 1;
public const int HttpRequestMaxBytes = 16 * 1024;
public const int BrowserResponseMaxBytes = 256 * 1024;
public const int SessionStreamEventMaxBytes = 32 * 1024;
public const int UdpDatagramMaxBytes = 1_200;
public const int MetadataMaxBytes = 4 * 1024;
public const int MetadataMaxKeys = 32;
@@ -23,6 +24,8 @@ public static class ContractLimits
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
public const int UdpCapabilityMaxCharacters = 192;
public const int ConnectionTicketMaxCharacters = 192;
public const int DerivedCredentialCharacters = 43;
public const int NatPunchRequestTokenCharacters = 192;
public const int LiteNetLibNatTokenMaxCharacters = 256;
public const int SessionCapacityMaxPlayers = 10_000;
}
@@ -45,11 +45,30 @@ public static class ContractValidation
public static bool IsDiagnosticCodeValid(string? value) =>
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
public static bool IsReportableConnectionOutcome(ConnectionOutcomeKind outcome) => outcome is
ConnectionOutcomeKind.Connected
or ConnectionOutcomeKind.Cancelled
or ConnectionOutcomeKind.TimedOut
or ConnectionOutcomeKind.StaleHost
or ConnectionOutcomeKind.TransportFailed
or ConnectionOutcomeKind.FallbackOffered
or ConnectionOutcomeKind.AttemptExpired
or ConnectionOutcomeKind.NoHostPresence
or ConnectionOutcomeKind.MediatorUnavailable
or ConnectionOutcomeKind.PunchTimedOut
or ConnectionOutcomeKind.DirectConnectTimedOut
or ConnectionOutcomeKind.HostRejected
or ConnectionOutcomeKind.TransportError
or ConnectionOutcomeKind.ManagerStopped
or ConnectionOutcomeKind.Disposed;
public static bool IsBuildVersionValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
!string.IsNullOrWhiteSpace(value)
&& IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
public static bool IsDisplayNameValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
!string.IsNullOrWhiteSpace(value)
&& IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
public static bool IsOpaqueHttpCredentialValid(string? value) =>
value is not null
@@ -5,9 +5,13 @@
<RootNamespace>FinalFactory.Rendezvous.Contracts</RootNamespace>
<IsPackable>true</IsPackable>
<PackageId>FinalFactory.Rendezvous.Contracts</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Versioned transport-neutral contracts for Final Factory Rendezvous.</Description>
<PackageTags>final-factory;multiplayer;contracts;godot</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" />
<None Update="README.md" Pack="true" PackagePath="\" />
<None Include="../../CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup>
</Project>
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class ReportConnectionOutcomeRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public ConnectionOutcomeKind Outcome { get; set; }
public ConnectionElapsedBucket ElapsedBucket { get; set; }
[Obsolete("Use ElapsedBucket. Exact elapsed time is accepted only for v1 compatibility and is not retained.")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int ElapsedMilliseconds { get; set; }
[Obsolete("Diagnostic codes are accepted only for v1 compatibility and are not retained.")]
public string? DiagnosticCode { get; set; }
}
public sealed class ReportConnectionOutcomeResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public bool Accepted { get; set; }
[JsonRequired]
public bool IsDuplicate { get; set; }
}
@@ -37,6 +37,9 @@ public sealed class CreateJoinAttemptResponse
[JsonRequired]
public string ClientPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public string ConnectionTicketDigest { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
public NetworkEndpoint? DedicatedFallback { get; set; }
@@ -53,6 +56,12 @@ public sealed class HostJoinAttempt
[JsonRequired]
public string HostPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public string ConnectionTicketDigest { get; set; } = string.Empty;
[JsonRequired]
public bool IsCancelled { get; set; }
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
@@ -67,26 +76,3 @@ public sealed class BrowseHostJoinAttemptsResponse
public string? NextCursor { get; set; }
}
public sealed class ReportConnectionOutcomeRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public ConnectionOutcomeKind Outcome { get; set; }
[JsonRequired]
public int ElapsedMilliseconds { get; set; }
public string? DiagnosticCode { get; set; }
}
public sealed class ReportConnectionOutcomeResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public bool Accepted { get; set; }
}
@@ -39,6 +39,8 @@ public sealed class SessionListing
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public NetworkEndpoint? DedicatedFallback { get; set; }
}
public sealed class RegisterSessionRequest
@@ -75,6 +77,8 @@ public sealed class RegisterSessionRequest
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public NetworkEndpoint? DedicatedFallback { get; set; }
}
public sealed class RegisterSessionResponse
@@ -99,6 +103,12 @@ public sealed class RegisterSessionResponse
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
[JsonRequired]
public int LeaseRenewAfterSeconds { get; set; }
[JsonRequired]
public int HostPresenceRefreshAfterSeconds { get; set; }
}
public sealed class RenewLeaseRequest
@@ -117,6 +127,9 @@ public sealed class RenewLeaseResponse
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
[JsonRequired]
public int RenewAfterSeconds { get; set; }
}
public sealed class UpdateSessionRequest
@@ -127,6 +140,10 @@ public sealed class UpdateSessionRequest
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
public RegionId? RegionId { get; set; }
public uint? ProtocolVersion { get; set; }
public ListingVisibility? Visibility { get; set; }
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
@@ -138,6 +155,8 @@ public sealed class UpdateSessionRequest
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public NetworkEndpoint? DedicatedFallback { get; set; }
}
public sealed class DeleteSessionRequest
@@ -165,6 +184,8 @@ public sealed class BrowseSessionsRequest
public RegionId? RegionId { get; set; }
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
public bool ExcludeFull { get; set; }
public string? Cursor { get; set; }
}
@@ -177,6 +198,32 @@ public sealed class BrowseSessionsResponse
public List<SessionListing> Items { get; set; } = [];
public string? NextCursor { get; set; }
[JsonRequired]
public string StreamCursor { get; set; } = string.Empty;
}
public enum SessionStreamEventKind
{
SessionUpsert = 1,
SessionRemove = 2,
Reset = 3,
Keepalive = 4,
}
public sealed class SessionStreamEvent
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionStreamEventKind Kind { get; set; }
[JsonRequired]
public string Cursor { get; set; } = string.Empty;
public SessionListing? Session { get; set; }
public SessionListingId? ListingId { get; set; }
}
public sealed class GetSessionResponse
@@ -0,0 +1,8 @@
# FinalFactory.Rendezvous.Contracts
Transport-neutral v1 HTTP/UDP contract types for Final Factory Rendezvous.
The package targets `netstandard2.1`, contains no Godot or LiteNetLib dependency,
and is versioned with the Client package and server release.
Compatibility and migration policy is maintained in the repository's
`docs/releases/README.md` document.
@@ -25,7 +25,9 @@ public static class ContractJson
options.AllowTrailingCommas = false;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.MaxDepth = 8;
// Nine is the minimum that lets ASP.NET generate the nullable fallback
// OpenAPI schema; the 16 KiB HTTP body limit still bounds parser work.
options.MaxDepth = 9;
options.NumberHandling = JsonNumberHandling.Strict;
options.PropertyNameCaseInsensitive = false;
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
@@ -0,0 +1,149 @@
using System.Security.Cryptography;
using System.Text;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class NatIntroductionToken
{
public JoinAttemptId AttemptId { get; set; }
public string ConnectionTicket { get; set; } = string.Empty;
public override string ToString() =>
$"[NatIntroductionToken {AttemptId}; ticket redacted]";
}
public static class NatIntroductionTokenCodec
{
public const int EncodedLength = ContractLimits.DerivedCredentialCharacters;
private const int DecodedLength = 32;
private const int AttemptIdLength = 16;
private const int AuthenticatorLength = DecodedLength - AttemptIdLength;
public static string Encode(JoinAttemptId attemptId, string derivedAuthenticator)
{
if (attemptId.Value == Guid.Empty
|| !ContractValidation.IsConnectionTicketValid(derivedAuthenticator)
|| !TryDecodeBase64Url(derivedAuthenticator, out byte[]? authenticator)
|| authenticator.Length != DecodedLength)
{
throw new ArgumentException("The NAT introduction token fields are invalid.");
}
byte[] payload = new byte[DecodedLength];
try
{
if (!attemptId.Value.TryWriteBytes(payload.AsSpan(0, AttemptIdLength)))
{
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
}
authenticator.AsSpan(0, AuthenticatorLength).CopyTo(payload.AsSpan(AttemptIdLength));
return EncodeBase64Url(payload);
}
finally
{
CryptographicOperations.ZeroMemory(authenticator);
CryptographicOperations.ZeroMemory(payload);
}
}
public static bool TryDecode(string? encoded, out NatIntroductionToken? token)
{
token = null;
if (!ContractValidation.IsConnectionTicketValid(encoded)
|| !TryDecodeBase64Url(encoded!, out byte[]? payload)
|| payload.Length != DecodedLength)
{
return false;
}
try
{
Guid attemptId = new(payload.AsSpan(0, AttemptIdLength));
if (attemptId == Guid.Empty)
{
return false;
}
token = new NatIntroductionToken
{
AttemptId = new JoinAttemptId(attemptId),
ConnectionTicket = encoded!,
};
return true;
}
finally
{
CryptographicOperations.ZeroMemory(payload);
}
}
public static string ComputeDigest(string connectionTicket)
{
if (!ContractValidation.IsConnectionTicketValid(connectionTicket))
{
throw new ArgumentException("The connection ticket is invalid.", nameof(connectionTicket));
}
byte[] encoded = Encoding.ASCII.GetBytes(connectionTicket);
byte[] digest;
using (SHA256 sha256 = SHA256.Create())
{
digest = sha256.ComputeHash(encoded);
}
CryptographicOperations.ZeroMemory(encoded);
try
{
return EncodeBase64Url(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public static bool MatchesDigest(string? connectionTicket, string? expectedDigest)
{
if (!ContractValidation.IsConnectionTicketValid(connectionTicket)
|| !ContractValidation.IsConnectionTicketValid(expectedDigest))
{
return false;
}
byte[] actual = Encoding.ASCII.GetBytes(ComputeDigest(connectionTicket!));
byte[] expected = Encoding.ASCII.GetBytes(expectedDigest!);
try
{
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
finally
{
CryptographicOperations.ZeroMemory(actual);
CryptographicOperations.ZeroMemory(expected);
}
}
private static bool TryDecodeBase64Url(string? encoded, out byte[] bytes)
{
bytes = [];
if (encoded is null || encoded.Length != EncodedLength)
{
return false;
}
try
{
bytes = Convert.FromBase64String(
encoded.Replace('-', '+').Replace('_', '/') + "=");
return true;
}
catch (FormatException)
{
return false;
}
}
private static string EncodeBase64Url(byte[] value) =>
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,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));
}
}
}

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