Compare commits

...

25 Commits

Author SHA1 Message Date
KyuubiYoru 589f802e2e fix(ci): smoke through service namespace (#1)
quality-gate / quality (push) Successful in 2m26s
quality-gate / container (push) Successful in 1m34s
2026-07-17 02:16:21 +02:00
KyuubiYoru 4423503bba fix(ci): reduce readiness probe pressure (#1)
quality-gate / quality (push) Successful in 2m40s
quality-gate / container (push) Failing after 4m34s
2026-07-17 02:06:39 +02:00
KyuubiYoru 74ae126ea7 fix(ci): allow cold container startup (#1)
quality-gate / quality (push) Successful in 2m39s
quality-gate / container (push) Failing after 3m11s
2026-07-17 01:59:30 +02:00
KyuubiYoru a77d4b801c fix(ci): isolate deployment smoke client (#1)
quality-gate / quality (push) Successful in 2m29s
quality-gate / container (push) Failing after 2m23s
2026-07-17 01:53:50 +02:00
KyuubiYoru 769336e424 fix(ci): run smoke on Compose network (#1)
quality-gate / quality (push) Successful in 2m37s
quality-gate / container (push) Failing after 1m39s
2026-07-17 01:42:28 +02:00
KyuubiYoru 41be7fbce5 fix(ci): join service to runner network (#1)
quality-gate / quality (push) Successful in 2m37s
quality-gate / container (push) Failing after 1m40s
2026-07-17 01:36:47 +02:00
KyuubiYoru 1bca034a52 fix(ci): route smoke through Docker gateway (#1)
quality-gate / quality (push) Successful in 2m37s
quality-gate / container (push) Failing after 1m51s
2026-07-17 01:29:30 +02:00
KyuubiYoru 8d98d888b3 fix(ci): resolve sibling-container bind sources (#1)
quality-gate / quality (push) Successful in 2m36s
quality-gate / container (push) Failing after 1m46s
2026-07-17 01:23:16 +02:00
KyuubiYoru ef07685d22 fix(ci): correct Docker inspect templates (#1)
quality-gate / quality (push) Successful in 2m36s
quality-gate / container (push) Failing after 1m42s
2026-07-17 01:14:44 +02:00
KyuubiYoru f9cb7f47e5 fix(ci): isolate Compose host ports (#1)
quality-gate / quality (push) Successful in 2m46s
quality-gate / container (push) Failing after 1m28s
2026-07-17 01:07:56 +02:00
KyuubiYoru de753b99e6 fix(deploy): validate complete smoke logs (#1)
quality-gate / quality (push) Successful in 2m47s
quality-gate / container (push) Failing after 2m18s
2026-07-17 00:58:28 +02:00
KyuubiYoru c3b3629515 fix(deploy): gate smoke readiness atomically (#1)
quality-gate / quality (push) Failing after 2m14s
quality-gate / container (push) Has been skipped
2026-07-17 00:54:04 +02:00
KyuubiYoru e8c07ee22a fix(deploy): synchronize smoke listing lookup (#1)
quality-gate / quality (push) Failing after 2m14s
quality-gate / container (push) Has been skipped
2026-07-17 00:48:13 +02:00
KyuubiYoru d42e6c99a3 fix(ci): provision TestClient runtime (#1)
quality-gate / quality (push) Failing after 2m18s
quality-gate / container (push) Has been skipped
2026-07-17 00:42:16 +02:00
KyuubiYoru c2f63db3ad test(client): stabilize stream integration gate (#1)
quality-gate / quality (push) Failing after 1m28s
quality-gate / container (push) Has been skipped
2026-07-17 00:34:29 +02:00
KyuubiYoru 99885f8c8c feat(observability): add diagnostic dashboards (#27)
quality-gate / quality (push) Failing after 1m31s
quality-gate / container (push) Has been skipped
2026-07-17 00:22:46 +02:00
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
129 changed files with 10908 additions and 301 deletions
+75 -18
View File
@@ -14,19 +14,45 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Install .NET SDK - name: Install .NET SDK
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with: with:
dotnet-version: 10.0.301 dotnet-version: |
8.0.128
10.0.301
- name: Restore locked dependencies - name: Restore locked dependencies
run: dotnet restore Rendezvous.slnx --locked-mode 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 - name: Verify formatting and analyzers
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
- name: Test dependency-free diagnostic dashboard
run: ./scripts/test-diagnostic-dashboard.sh
- name: Test observability dashboard provisioning
run: ./scripts/test-observability-assets.sh
- name: Build - name: Build
run: dotnet build Rendezvous.slnx --configuration Release --no-restore run: dotnet build Rendezvous.slnx --configuration Release --no-restore
@@ -98,12 +124,14 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET SDK - name: Install .NET SDK
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with: with:
dotnet-version: 10.0.301 dotnet-version: |
8.0.128
10.0.301
- name: Build deployment diagnostic - name: Build deployment diagnostic
run: | run: |
@@ -122,29 +150,58 @@ jobs:
rm -f "$secret" rm -f "$secret"
} }
trap cleanup EXIT trap cleanup EXIT
umask 077
install -d -m 0700 deploy/compose/secrets install -d -m 0700 deploy/compose/secrets
openssl rand -out "$secret" 32 openssl rand -out "$secret" 32
chmod 0600 "$secret"
publisher_credential="$(RENDEZVOUS_SMOKE_LOCAL_KEY="$secret" \
./scripts/mint-local-publisher-credential.sh)"
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$publisher_credential"
chmod 0444 "$secret" chmod 0444 "$secret"
export RENDEZVOUS_UID=1654 export RENDEZVOUS_UID=1654
export RENDEZVOUS_GID=1654 export RENDEZVOUS_GID=1654
docker compose -f "$compose_file" up --build --detach port_suffix="$(( ${GITHUB_RUN_ID:-$$} % 10000 ))"
export RENDEZVOUS_HTTP_HOST_PORT="$(( 20000 + port_suffix ))"
export RENDEZVOUS_UDP_HOST_PORT="$(( 40000 + port_suffix ))"
runner_workspace_source="$(docker inspect "$HOSTNAME" | jq -er \
--arg destination "$GITHUB_WORKSPACE" \
'.[0].Mounts[] | select(.Destination == $destination) | .Source')"
export RENDEZVOUS_CONFIG_SOURCE="$runner_workspace_source/deploy/compose/appsettings.Production.json"
export RENDEZVOUS_SECRET_SOURCE="$runner_workspace_source/deploy/compose/secrets/signing-key"
export SOURCE_REVISION_ID="$GITHUB_SHA"
smoke_client_image="rendezvous-smoke-client:${GITHUB_SHA}"
docker build --file eng/release-builder.Dockerfile \
--target release-builder \
--tag "$smoke_client_image" .
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)" container_id="$(docker compose -f "$compose_file" ps -q rendezvous)"
test -n "$container_id" test -n "$container_id"
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654" test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container_id")" = "true" 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 "/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" 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 docker run --rm \
if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1; then --network "container:${container_id}" \
break --user "$(id -u):$(id -g)" \
fi --env HOME=/tmp \
if (( attempt == 100 )); then --env RENDEZVOUS_SMOKE_HTTP_URL=http://127.0.0.1:8080/ \
--env RENDEZVOUS_SMOKE_UDP_ENDPOINT=127.0.0.1:9050 \
--env RENDEZVOUS_PUBLISHER_CREDENTIAL \
--volume "$runner_workspace_source:/source:ro" \
--workdir /source \
"$smoke_client_image" \
bash -lc '
for attempt in {1..180}; do
curl --fail --silent "${RENDEZVOUS_SMOKE_HTTP_URL%/}/health/ready" >/dev/null 2>&1 && exec ./scripts/smoke-deployment.sh
sleep 1
done
exit 1
' || {
docker compose -f "$compose_file" logs rendezvous docker compose -f "$compose_file" logs rendezvous
exit 1 exit 1
fi }
sleep 0.1
done
./scripts/smoke-deployment.sh
docker compose -f "$compose_file" stop --timeout 40 rendezvous docker compose -f "$compose_file" stop --timeout 40 rendezvous
test "$(docker inspect --format '{{.State.Running}}' "$container_id")" = "false" test "$(docker inspect --format '{{.State.Running}}' "$container_id")" = "false"
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = "0" test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = "0"
+221
View File
@@ -0,0 +1,221 @@
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 SDKs
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: |
8.0.128
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
umask 077
install -d -m 0700 deploy/compose/secrets
openssl rand -out "$secret" 32
chmod 0600 "$secret"
publisher_credential="$(RENDEZVOUS_SMOKE_LOCAL_KEY="$secret" \
./scripts/mint-local-publisher-credential.sh)"
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$publisher_credential"
chmod 0444 "$secret"
export RENDEZVOUS_UID=1654
export RENDEZVOUS_GID=1654
port_suffix="$(( ${GITHUB_RUN_ID:-$$} % 10000 ))"
export RENDEZVOUS_HTTP_HOST_PORT="$(( 20000 + port_suffix ))"
export RENDEZVOUS_UDP_HOST_PORT="$(( 40000 + port_suffix ))"
runner_workspace_source="$(docker inspect "$HOSTNAME" | jq -er \
--arg destination "$GITHUB_WORKSPACE" \
'.[0].Mounts[] | select(.Destination == $destination) | .Source')"
export RENDEZVOUS_CONFIG_SOURCE="$runner_workspace_source/deploy/compose/appsettings.Production.json"
export RENDEZVOUS_SECRET_SOURCE="$runner_workspace_source/deploy/compose/secrets/signing-key"
export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
docker compose -f deploy/compose/compose.yaml up --detach --no-build
container_id="$(docker compose -f deploy/compose/compose.yaml ps -q rendezvous)"
test -n "$container_id"
docker run --rm \
--network "container:${container_id}" \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
--env RENDEZVOUS_SMOKE_HTTP_URL=http://127.0.0.1:8080/ \
--env RENDEZVOUS_SMOKE_UDP_ENDPOINT=127.0.0.1:9050 \
--env RENDEZVOUS_PUBLISHER_CREDENTIAL \
--volume "$runner_workspace_source:/source:ro" \
--workdir /source \
"$RENDEZVOUS_RELEASE_BUILDER" \
bash -lc '
for attempt in {1..180}; do
curl --fail --silent "${RENDEZVOUS_SMOKE_HTTP_URL%/}/health/ready" >/dev/null 2>&1 && exec ./scripts/smoke-deployment.sh
sleep 1
done
exit 1
' || {
docker compose -f deploy/compose/compose.yaml logs rendezvous
exit 1
}
- 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"
+2
View File
@@ -8,5 +8,7 @@ TestResults/
*.userosscache *.userosscache
deploy/compose/.smoke.env deploy/compose/.smoke.env
artifacts/ artifacts/
__pycache__/
*.pyc
deploy/compose/secrets/* deploy/compose/secrets/*
!deploy/compose/secrets/.gitignore !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> <Project>
<Import Project="eng/Versions.props" />
<PropertyGroup> <PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel> <AnalysisLevel>latest-recommended</AnalysisLevel>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild> <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
@@ -8,8 +9,35 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <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> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode> <RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<Target Name="ConfigureGiteaSourceLink"
BeforeTargets="_GenerateSourceLinkFile"
DependsOnTargets="InitializeSourceControlInformation">
<ItemGroup>
<SourceRoot Update="@(SourceRoot)"
SourceLinkUrl="$(RepositoryUrl)/raw/commit/$(SourceRevisionId)/*" />
</ItemGroup>
</Target>
</Project> </Project>
+1 -1
View File
@@ -4,7 +4,7 @@
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
+6 -1
View File
@@ -1,8 +1,10 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e # syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build
ARG SOURCE_REVISION_ID
WORKDIR /source WORKDIR /source
COPY Directory.Build.props Directory.Packages.props NuGet.config global.json Rendezvous.slnx ./ 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.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/ 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 RUN dotnet restore src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj --locked-mode
@@ -14,7 +16,10 @@ RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Se
--no-restore \ --no-restore \
--output /out \ --output /out \
/p:UseAppHost=false \ /p:UseAppHost=false \
/p:OpenApiGenerateDocuments=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 FROM mcr.microsoft.com/dotnet/aspnet:10.0.9-noble-chiseled@sha256:f820c4fbfb8bb204c3bbe05c69d48cd039cd0e67aa8f13ac1cec168819b90643 AS runtime
+28 -6
View File
@@ -84,10 +84,10 @@ directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client, SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
deterministic NAT topology harness, hostile-input controls, deterministic NAT topology harness, hostile-input controls,
observability/operator surface, secure single-active Linux deployment, and observability/operator surface, secure single-active Linux deployment, and
numeric capacity/resilience gates are implemented. Packaging, consumer pilots, numeric capacity/resilience gates, reproducible signed release pipeline, consumer
and final production-readiness gates remain in progress; pilot integrations, and production-readiness decision framework are implemented.
participating games must not treat the current repository as a finished production Deployment-specific external canaries and optional roadmap follow-ups remain;
service until those gates land. participating games must not treat a checkout alone as production approval.
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/README.md). threat model are indexed in [the architecture documentation](docs/architecture/README.md).
@@ -100,22 +100,44 @@ defined in [hostile-input and overload protection](docs/security/abuse-protectio
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
operator controls are defined in the operator controls are defined in the
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md). [observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
The optional public session diagnostic and the private provisioned Grafana stack
are described in [diagnostic dashboards](docs/operations/diagnostic-dashboards.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 The pinned non-root container, production topology, graceful drain, Linux
hardening, smoke procedure, and recovery lifecycle are documented in hardening, smoke procedure, and recovery lifecycle are documented in
[secure single-active Linux deployment](docs/deployment/linux.md). [secure single-active Linux deployment](docs/deployment/linux.md).
The numeric core-state candidate profile, public launch objectives, accelerated The numeric core-state candidate profile, public launch objectives, accelerated
soak, resilience matrix, and single-active scaling decision are recorded in soak, resilience matrix, and single-active scaling decision are recorded in
[capacity and resilience gates](docs/operations/capacity-and-resilience.md). [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 The scriptable host/browser/join diagnostic and its stable automation contract are
documented in the [TestClient integration guide](docs/integration/test-client.md). 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 The always-on three-party scenarios, optional Linux namespace topology, and
simulation limits are documented in the simulation limits are documented in the
[deterministic topology harness](docs/integration/topology-harness.md). [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 ## Development
The repository pins .NET SDK 10.0.301. From a clean clone, run the same gates as The repository pins .NET SDK 10.0.301. The full process-test gate also requires
CI from the repository root: the .NET 8 runtime because the public TestClient deliberately targets `net8.0`;
CI installs SDK 8.0.128 to supply the pinned 8.0.28 runtime. From a clean clone,
run the same gates as CI from the repository root:
```bash ```bash
dotnet restore Rendezvous.slnx --locked-mode dotnet restore Rendezvous.slnx --locked-mode
+61 -5
View File
@@ -1,5 +1,5 @@
{ {
"AllowedHosts": "localhost;127.0.0.1", "AllowedHosts": "localhost;127.0.0.1;rendezvous",
"Rendezvous": { "Rendezvous": {
"Deployment": { "Deployment": {
"PublicHttpBaseUrl": "https://localhost/", "PublicHttpBaseUrl": "https://localhost/",
@@ -14,6 +14,29 @@
"ListenAddress": "0.0.0.0", "ListenAddress": "0.0.0.0",
"Port": 9050 "Port": 9050
}, },
"Diagnostics": {
"Enabled": false,
"PollIntervalSeconds": 10,
"MaximumRenderedSessions": 100,
"Scopes": [
{
"GameId": "space-game",
"EnvironmentId": "smoke",
"ProtocolVersions": [1, 2],
"Regions": ["local"]
},
{
"GameId": "unscouted",
"EnvironmentId": "smoke",
"ProtocolVersions": [1],
"Regions": ["local"]
}
]
},
"Metrics": {
"Enabled": false,
"BearerTokenSecretReference": "file:/run/secrets/rendezvous-metrics-token"
},
"AbuseProtection": { "AbuseProtection": {
"TrustedProxyAddresses": ["127.0.0.1"], "TrustedProxyAddresses": ["127.0.0.1"],
"OperatorAllowedAddresses": ["127.0.0.1"] "OperatorAllowedAddresses": ["127.0.0.1"]
@@ -32,6 +55,16 @@
"NotBefore": "2026-01-01T00:00:00Z", "NotBefore": "2026-01-01T00:00:00Z",
"SignUntil": "2100-01-01T00:00:00Z", "SignUntil": "2100-01-01T00:00:00Z",
"VerifyUntil": "2100-01-02T00: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": [ "Games": [
@@ -39,18 +72,41 @@
"GameId": "space-game", "GameId": "space-game",
"EnvironmentId": "smoke", "EnvironmentId": "smoke",
"Enabled": true, "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], "ProtocolVersions": [1],
"Regions": ["local"], "Regions": ["local"],
"VisibilityModes": ["Public"], "VisibilityModes": ["Public"],
"PublisherTrustModes": ["ManagedDedicated"], "PublisherTrustModes": ["ManagedDedicated"],
"MetadataValueMaxBytes": {}, "MetadataValueMaxBytes": {
"RequiredMetadataKeys": [], "mode": 32,
"world": 64,
"mods": 64
},
"RequiredMetadataKeys": ["mode", "world", "mods"],
"MetadataMaxBytes": 512, "MetadataMaxBytes": 512,
"MetadataMaxKeys": 0, "MetadataMaxKeys": 3,
"MaxListingsPerPrincipal": 10, "MaxListingsPerPrincipal": 10,
"MaxAnonymousListingsPerAddress": 0, "MaxAnonymousListingsPerAddress": 0,
"MaxActiveJoinAttempts": 100, "MaxActiveJoinAttempts": 100,
"FallbackPolicy": "Disabled" "FallbackPolicy": "DedicatedEndpointAllowed"
} }
] ]
} }
+7 -5
View File
@@ -2,7 +2,7 @@ name: rendezvous-local
services: services:
rendezvous: rendezvous:
image: finalfactory/rendezvous:local image: "${RENDEZVOUS_IMAGE:-finalfactory/rendezvous:local}"
build: build:
context: ../.. context: ../..
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -27,9 +27,11 @@ services:
environment: environment:
ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_HTTP_PORTS: "8080" ASPNETCORE_HTTP_PORTS: "8080"
Rendezvous__Diagnostics__Enabled: "${RENDEZVOUS_DIAGNOSTICS_ENABLED:-false}"
Rendezvous__Metrics__Enabled: "${RENDEZVOUS_METRICS_ENABLED:-false}"
volumes: volumes:
- ./appsettings.Production.json:/app/appsettings.Production.json:ro - ${RENDEZVOUS_CONFIG_SOURCE:-./appsettings.Production.json}:/app/appsettings.Production.json:ro
- ./secrets/signing-key:/run/secrets/rendezvous-signing-key:ro - ${RENDEZVOUS_SECRET_SOURCE:-./secrets/signing-key}:/run/secrets/rendezvous-signing-key:ro
ports: ports:
- "127.0.0.1:8080:8080/tcp" - "127.0.0.1:${RENDEZVOUS_HTTP_HOST_PORT:-8080}:8080/tcp"
- "9050:9050/udp" - "${RENDEZVOUS_UDP_HOST_PORT:-9050}:9050/udp"
+71
View File
@@ -0,0 +1,71 @@
services:
rendezvous:
environment:
Rendezvous__Metrics__Enabled: "true"
volumes:
- ../observability/secrets/rendezvous-metrics-token:/run/secrets/rendezvous-metrics-token:ro
prometheus:
image: prom/prometheus:v3.13.1@sha256:3c42b892cf723fa54d2f262c37a0e1f80aa8c8ddb1da7b9b0df9455a35a7f893
user: "65534:65534"
read_only: true
init: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 128
mem_limit: 512m
cpus: 1.0
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
volumes:
- ../observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ../observability/secrets/rendezvous-metrics-token:/run/secrets/rendezvous-metrics-token:ro
- prometheus-data:/prometheus
depends_on:
- rendezvous
grafana:
image: grafana/grafana:13.1.0@sha256:121a7a9ece6dc10b969f1f96eed64b4f07dfac0d0b8abc070f7cb83bbde86f63
user: "472:472"
read_only: true
init: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 128
mem_limit: 512m
cpus: 1.0
restart: unless-stopped
environment:
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_PLUGINS_PREINSTALL_DISABLED: "true"
GF_PLUGINS_PREINSTALL_AUTO_UPDATE: "false"
GF_SECURITY_ADMIN_USER: "rendezvous-admin"
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana-admin-password
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_SERVER_DOMAIN: localhost
GF_SERVER_ROOT_URL: http://localhost:3000
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=32m,uid=472,gid=472,mode=0700
volumes:
- ../observability/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ../observability/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ../observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
- ../observability/secrets/grafana-admin-password:/run/secrets/grafana-admin-password:ro
- grafana-data:/var/lib/grafana
ports:
- "127.0.0.1:3000:3000/tcp"
depends_on:
- prometheus
volumes:
prometheus-data:
grafana-data:
@@ -0,0 +1,292 @@
{
"annotations": {"list": []},
"description": "Privacy-safe operational view of the single-active Rendezvous service. Capacity percentages use the approved 25,000 listing and 10,000 active-attempt launch envelope.",
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"id": 1,
"title": "Service",
"description": "Prometheus can authenticate to and scrape the Rendezvous process.",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 0},
"fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"color": "red", "text": "DOWN"}, "1": {"color": "green", "text": "UP"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}, "overrides": []},
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
"targets": [{"editorMode": "code", "expr": "up{job=\"rendezvous\"}", "legendFormat": "Rendezvous", "range": true, "refId": "A"}]
},
{
"id": 2,
"title": "Store",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 0},
"fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"color": "red", "text": "UNAVAILABLE"}, "1": {"color": "green", "text": "AVAILABLE"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}, "overrides": []},
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
"targets": [{"editorMode": "code", "expr": "rendezvous_store_available", "legendFormat": "Store", "range": true, "refId": "A"}]
},
{
"id": 3,
"title": "Drain",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 4, "x": 8, "y": 0},
"fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"color": "green", "text": "ACCEPTING"}, "1": {"color": "orange", "text": "DRAINING"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 1}]}}, "overrides": []},
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
"targets": [{"editorMode": "code", "expr": "rendezvous_store_draining", "legendFormat": "Drain", "range": true, "refId": "A"}]
},
{
"id": 4,
"title": "Listings",
"description": "Percentage of the approved 25,000-listing single-process envelope.",
"type": "gauge",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent", "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 70}, {"color": "red", "value": 90}]}}, "overrides": []},
"options": {"minVizHeight": 75, "minVizWidth": 75, "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showThresholdLabels": false, "showThresholdMarkers": true, "sizing": "auto"},
"targets": [{"editorMode": "code", "expr": "100 * rendezvous_store_active_listings / 25000", "legendFormat": "Listings", "range": true, "refId": "A"}]
},
{
"id": 5,
"title": "Join attempts",
"description": "Percentage of the approved 10,000 active-attempt single-process envelope.",
"type": "gauge",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent", "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 70}, {"color": "red", "value": 90}]}}, "overrides": []},
"options": {"minVizHeight": 75, "minVizWidth": 75, "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showThresholdLabels": false, "showThresholdMarkers": true, "sizing": "auto"},
"targets": [{"editorMode": "code", "expr": "100 * rendezvous_store_active_attempts / 10000", "legendFormat": "Attempts", "range": true, "refId": "A"}]
},
{
"id": 6,
"title": "HTTP request rate by operation",
"description": "Registration, renewal, browse, and join issuance appear as bounded operation names.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 8, "w": 8, "x": 0, "y": 4},
"fieldConfig": {"defaults": {"unit": "reqps"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (operation) (rate(rendezvous_http_requests_total[5m]))", "legendFormat": "{{operation}}", "range": true, "refId": "A"}]
},
{
"id": 7,
"title": "HTTP p95 latency",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 8, "w": 8, "x": 8, "y": 4},
"fieldConfig": {"defaults": {"unit": "ms", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 200}]}}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "histogram_quantile(0.95, sum by (le, operation) (rate(rendezvous_http_duration_milliseconds_bucket[5m])))", "legendFormat": "{{operation}}", "range": true, "refId": "A"}]
},
{
"id": 8,
"title": "HTTP error and shedding rate",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 8, "w": 8, "x": 16, "y": 4},
"fieldConfig": {"defaults": {"unit": "reqps"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (status_code) (rate(rendezvous_http_requests_total{status_code=~\"4..|5..\"}[5m]))", "legendFormat": "HTTP {{status_code}}", "range": true, "refId": "A"}]
},
{
"id": 9,
"title": "Browser live-update load",
"description": "Active public SSE clients, tenant scopes, and bounded replay entries.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 12},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "rendezvous_browser_sse_subscribers", "legendFormat": "Subscribers", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "rendezvous_browser_sse_tenants", "legendFormat": "Tenant scopes", "range": true, "refId": "B"},
{"editorMode": "code", "expr": "rendezvous_browser_replay_entries", "legendFormat": "Replay entries", "range": true, "refId": "C"}
]
},
{
"id": 10,
"title": "UDP mediation results",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 12},
"fieldConfig": {"defaults": {"unit": "pps"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (operation, result) (rate(rendezvous_udp_results_total[5m]))", "legendFormat": "{{operation}} · {{result}}", "range": true, "refId": "A"}]
},
{
"id": 11,
"title": "UDP p95 processing latency",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 12},
"fieldConfig": {"defaults": {"unit": "ms", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 100}]}}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "histogram_quantile(0.95, sum by (le, operation) (rate(rendezvous_udp_duration_milliseconds_bucket[5m])))", "legendFormat": "{{operation}}", "range": true, "refId": "A"}]
},
{
"id": 12,
"title": "UDP ingress and admitted response budget",
"description": "Traffic bytes observed by the process and the conservative maximum response budget admitted for successful introductions; this is not actual egress.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 19},
"fieldConfig": {"defaults": {"unit": "Bps"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "sum by (operation) (rate(rendezvous_udp_received_bytes_total[5m]))", "legendFormat": "Ingress · {{operation}}", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "sum by (operation) (rate(rendezvous_udp_response_budget_bytes_total[5m]))", "legendFormat": "Response budget · {{operation}}", "range": true, "refId": "B"}
]
},
{
"id": 13,
"title": "Admission-control drops",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 19},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (transport, partition) (rate(rendezvous_limiter_drops_total[5m]))", "legendFormat": "{{transport}} · {{partition}}", "range": true, "refId": "A"}]
},
{
"id": 14,
"title": "Direct-connect outcomes",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 19},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (outcome, elapsed_bucket) (rate(rendezvous_connection_outcomes_total[5m]))", "legendFormat": "{{outcome}} · {{elapsed_bucket}}", "range": true, "refId": "A"}]
},
{
"id": 15,
"title": "Pairing p95 latency",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 26},
"fieldConfig": {"defaults": {"unit": "ms", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 100}]}}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "histogram_quantile(0.95, rate(rendezvous_pairing_latency_milliseconds_bucket[5m]))", "legendFormat": "Pairing p95", "range": true, "refId": "A"}]
},
{
"id": 16,
"title": "Presence and expiry state",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 26},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "rendezvous_store_fresh_presence_bindings", "legendFormat": "Fresh presence", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "rendezvous_store_awaiting_presence_listings", "legendFormat": "Awaiting presence", "range": true, "refId": "B"},
{"editorMode": "code", "expr": "rate(rendezvous_store_expiry_churn_total[5m])", "legendFormat": "Expiry churn/s", "range": true, "refId": "C"}
]
},
{
"id": 17,
"title": "Signing key state",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 26},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "rendezvous_signing_keys", "legendFormat": "{{state}}", "range": true, "refId": "A"}]
},
{
"id": 18,
"title": "Minimum signing window",
"description": "Page below seven days; escalate below 24 hours.",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 33},
"fieldConfig": {"defaults": {"unit": "s", "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "orange", "value": 86400}, {"color": "green", "value": 604800}]}}, "overrides": []},
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
"targets": [{"editorMode": "code", "expr": "rendezvous_signing_key_sign_seconds_remaining", "legendFormat": "Signing window", "range": true, "refId": "A"}]
},
{
"id": 19,
"title": "Operator authentication",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 33},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (result) (rate(rendezvous_operator_authentication_total[5m]))", "legendFormat": "{{result}}", "range": true, "refId": "A"}]
},
{
"id": 20,
"title": "Privileged audit outcomes",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 33},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (action, result) (rate(rendezvous_audit_events_total[5m]))", "legendFormat": "{{action}} · {{result}}", "range": true, "refId": "A"}]
},
{
"id": 21,
"title": "Metrics access",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 33},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (result) (rate(rendezvous_metrics_scrapes_total[5m]))", "legendFormat": "{{result}}", "range": true, "refId": "A"}]
},
{
"id": 22,
"title": "Process memory",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 38},
"fieldConfig": {"defaults": {"unit": "bytes", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 1073741824}, {"color": "red", "value": 1610612736}]}}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "process_resident_memory_bytes", "legendFormat": "Resident", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "dotnet_gc_heap_size_bytes", "legendFormat": "Managed heap", "range": true, "refId": "B"}
]
},
{
"id": 23,
"title": "Process CPU and threads",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 38},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": [{"matcher": {"id": "byName", "options": "CPU cores"}, "properties": [{"id": "unit", "value": "cores"}]}]},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "rate(process_cpu_seconds_total[5m])", "legendFormat": "CPU cores", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "process_threads", "legendFormat": "Process threads", "range": true, "refId": "B"},
{"editorMode": "code", "expr": "dotnet_thread_pool_threads", "legendFormat": "Thread-pool threads", "range": true, "refId": "C"}
]
},
{
"id": 24,
"title": "Descriptor and GC pressure",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 38},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "process_open_file_descriptors", "legendFormat": "Open descriptors", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "sum(rate(dotnet_gc_collections_total[5m]))", "legendFormat": "GC collections/s", "range": true, "refId": "B"},
{"editorMode": "code", "expr": "dotnet_thread_pool_available_worker_threads", "legendFormat": "Available workers", "range": true, "refId": "C"}
]
}
],
"refresh": "15s",
"schemaVersion": 41,
"tags": ["rendezvous", "operations", "privacy-safe"],
"templating": {"list": []},
"time": {"from": "now-1h", "to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "Rendezvous operational overview",
"uid": "rendezvous-overview",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,13 @@
apiVersion: 1
providers:
- name: Rendezvous
orgId: 1
folder: Rendezvous
type: file
disableDeletion: true
allowUiUpdates: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,17 @@
apiVersion: 1
deleteDatasources:
- name: Rendezvous Prometheus
orgId: 1
datasources:
- name: Rendezvous Prometheus
uid: rendezvous-prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
httpMethod: POST
timeInterval: 15s
@@ -0,0 +1,14 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
service: rendezvous
scrape_configs:
- job_name: rendezvous
scheme: http
metrics_path: /metrics
bearer_token_file: /run/secrets/rendezvous-metrics-token
static_configs:
- targets:
- rendezvous:8080
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+290 -1
View File
@@ -1177,6 +1177,159 @@
} }
} }
}, },
"/v1/sessions/stream": {
"get": {
"tags": [
"Sessions"
],
"operationId": "StreamSessions",
"parameters": [
{
"name": "contractVersion",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "gameId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "environmentId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "protocolVersion",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "uint32"
}
},
{
"name": "regionId",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "excludeFull",
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "streamCursor",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "Last-Event-ID",
"in": "header",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"headers": {
"X-Rendezvous-Correlation-ID": {
"description": "Safe request correlation identifier generated by the service.",
"schema": {
"type": "string"
}
}
},
"content": {
"text/event-stream": {
"schema": {
"$ref": "#/components/schemas/SessionStreamEvent"
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"X-Rendezvous-Correlation-ID": {
"description": "Safe request correlation identifier generated by the service.",
"schema": {
"type": "string"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"429": {
"description": "Too Many Requests",
"headers": {
"X-Rendezvous-Correlation-ID": {
"description": "Safe request correlation identifier generated by the service.",
"schema": {
"type": "string"
}
},
"Retry-After": {
"description": "Whole seconds before the caller should retry (1-60).",
"schema": {
"type": "integer",
"format": "int32"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"headers": {
"X-Rendezvous-Correlation-ID": {
"description": "Safe request correlation identifier generated by the service.",
"schema": {
"type": "string"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/v1/sessions/{listingId}/join-attempts": { "/v1/sessions/{listingId}/join-attempts": {
"get": { "get": {
"tags": [ "tags": [
@@ -2657,7 +2810,8 @@
"BrowseSessionsResponse": { "BrowseSessionsResponse": {
"required": [ "required": [
"contractVersion", "contractVersion",
"items" "items",
"streamCursor"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -2676,6 +2830,9 @@
"null", "null",
"string" "string"
] ]
},
"streamCursor": {
"type": "string"
} }
} }
}, },
@@ -2931,6 +3088,59 @@
} }
} }
}, },
"OperatorCompatibilityResponse": {
"required": [
"serverVersion",
"minimumClientVersion",
"maximumClientMajorVersion",
"httpContractVersions",
"udpContractVersions",
"connectionTicketFormatVersions",
"liteNetLibMajorVersion",
"gameplayProtocolCompatibility"
],
"type": "object",
"properties": {
"serverVersion": {
"type": "string"
},
"minimumClientVersion": {
"type": "string"
},
"maximumClientMajorVersion": {
"type": "integer",
"format": "int32"
},
"httpContractVersions": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"udpContractVersions": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"connectionTicketFormatVersions": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"liteNetLibMajorVersion": {
"type": "integer",
"format": "int32"
},
"gameplayProtocolCompatibility": {
"type": "string"
}
}
},
"OperatorReadinessResponse": { "OperatorReadinessResponse": {
"required": [ "required": [
"httpListener", "httpListener",
@@ -3009,6 +3219,7 @@
"OperatorStatusResponse": { "OperatorStatusResponse": {
"required": [ "required": [
"status", "status",
"compatibility",
"readiness", "readiness",
"store", "store",
"tenants", "tenants",
@@ -3020,6 +3231,9 @@
"status": { "status": {
"type": "string" "type": "string"
}, },
"compatibility": {
"$ref": "#/components/schemas/OperatorCompatibilityResponse"
},
"readiness": { "readiness": {
"$ref": "#/components/schemas/OperatorReadinessResponse" "$ref": "#/components/schemas/OperatorReadinessResponse"
}, },
@@ -3488,6 +3702,54 @@
"type": "string", "type": "string",
"format": "uuid" "format": "uuid"
}, },
"SessionStreamEvent": {
"required": [
"contractVersion",
"kind",
"cursor"
],
"type": "object",
"properties": {
"contractVersion": {
"type": "integer",
"format": "int32"
},
"kind": {
"$ref": "#/components/schemas/SessionStreamEventKind"
},
"cursor": {
"type": "string"
},
"session": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/SessionListing"
}
]
},
"listingId": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/SessionListingId"
}
]
}
}
},
"SessionStreamEventKind": {
"enum": [
"sessionUpsert",
"sessionRemove",
"reset",
"keepalive"
]
},
"UpdateSessionRequest": { "UpdateSessionRequest": {
"required": [ "required": [
"contractVersion", "contractVersion",
@@ -3506,6 +3768,33 @@
"leaseToken": { "leaseToken": {
"type": "string" "type": "string"
}, },
"regionId": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/RegionId"
}
]
},
"protocolVersion": {
"type": [
"null",
"integer"
],
"format": "uint32"
},
"visibility": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ListingVisibility"
}
]
},
"buildVersion": { "buildVersion": {
"type": "string" "type": "string"
}, },
+2
View File
@@ -29,6 +29,8 @@ engine, transport, or server dependency therefore fails the normal test gate.
## Supported toolchain ## Supported toolchain
- Build SDK: .NET SDK 10.0.301, pinned by `global.json`. - Build SDK: .NET SDK 10.0.301, pinned by `global.json`.
- Verification runtime: .NET SDK 8.0.128 supplies the pinned .NET 8.0.28
runtime used by TestClient process tests and release verification.
- Server runtime: .NET 10 LTS. - Server runtime: .NET 10 LTS.
- Client/contracts compatibility target: .NET Standard 2.1, consumable by the - Client/contracts compatibility target: .NET Standard 2.1, consumable by the
.NET 8-or-later runtime used by current Godot 4 C# projects. .NET 8-or-later runtime used by current Godot 4 C# projects.
+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 vectors and a public-API snapshot make accidental wire or source compatibility
changes fail the normal test gate. changes fail the normal test gate.
Any incompatible change requires a new contract version. Additive JSON fields Readers ignore unknown JSON members, but the release gate deliberately treats
may be introduced within v1 because v1 readers ignore unknown object members. 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.
+24 -3
View File
@@ -42,9 +42,27 @@ test "$RENDEZVOUS_UID" -ne 0
docker compose -f deploy/compose/compose.yaml up --build --detach docker compose -f deploy/compose/compose.yaml up --build --detach
``` ```
`deploy/compose/appsettings.Production.json` is an isolated loopback smoke The example defaults to host TCP 8080 and UDP 9050. On a shared host, set
profile, not an Internet template: it deliberately opts into private advertised `RENDEZVOUS_HTTP_HOST_PORT` and `RENDEZVOUS_UDP_HOST_PORT` before starting
endpoints and has no TLS proxy. Its random key is ignored by Git and must be Compose, then point `RENDEZVOUS_SMOKE_HTTP_URL` and
`RENDEZVOUS_SMOKE_UDP_ENDPOINT` at those published ports. The container ports
and production-advertised service ports remain 8080/9050. Sibling-container CI
runners may also set `RENDEZVOUS_CONFIG_SOURCE` and
`RENDEZVOUS_SECRET_SOURCE` to host-visible absolute bind-source paths; local
operators should normally keep the checked-in relative defaults. CI runs its
ephemeral smoke client as a sidecar in the service container's network namespace
to avoid runner-specific bridge and host-routing policy; it does not widen the
default loopback HTTP publication. It mints the disposable publisher credential
while the generated key is still owner-private, then makes the key read-only for
the non-root service container. Automation probes readiness once per second and
allows up to three minutes for a cold, resource-constrained image to become
ready; the protocol smoke retains its separate, stricter scenario timeout.
`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 deleted after use. Its deliberately long key window only keeps this disposable
local fixture usable; production keys require short, reviewed rotation windows. local fixture usable; production keys require short, reviewed rotation windows.
Production configuration must use its real public names and must leave Production configuration must use its real public names and must leave
@@ -177,6 +195,9 @@ dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
For the local Compose profile, the script derives a ten-minute diagnostic For the local Compose profile, the script derives a ten-minute diagnostic
publisher credential from the ignored local key without printing either secret. 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 For production, do not copy the signing key to the smoke host. Instead inject a
short-lived, region-scoped credential through short-lived, region-scoped credential through
`RENDEZVOUS_PUBLISHER_CREDENTIAL`, and set the external endpoints: `RENDEZVOUS_PUBLISHER_CREDENTIAL`, and set the external endpoints:
+35 -35
View File
@@ -1,7 +1,7 @@
{ {
"schemaVersion": 2, "schemaVersion": 2,
"evidenceVersion": "v2", "evidenceVersion": "v2",
"generatedAt": "2026-07-16T14:10:53.6981858+00:00", "generatedAt": "2026-07-16T20:28:43.2873744+00:00",
"profile": "candidate", "profile": "candidate",
"runtime": { "runtime": {
"framework": ".NET 10.0.9", "framework": ".NET 10.0.9",
@@ -14,14 +14,14 @@
"cpuQuota": "not-enforced", "cpuQuota": "not-enforced",
"memoryLimit": "not-enforced", "memoryLimit": "not-enforced",
"garbageCollector": "workstation", "garbageCollector": "workstation",
"commitSha": "cf14836d48b0b4aaa67f99433f4fba3585bcd2bb", "commitSha": "00d5ff776408e7d80ce6648953e62a7233aca35c",
"treeState": "clean", "treeState": "clean",
"command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh", "command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh",
"imageDigest": "not-containerized", "imageDigest": "not-containerized",
"workloadSeed": "fixed-sequences-random-identifiers", "workloadSeed": "fixed-sequences-random-identifiers",
"capacityPhaseAverageCpuPercent": 56.37724115383554, "capacityPhaseAverageCpuPercent": 55.52666859166872,
"peakWorkingSetBytes": 169705472, "peakWorkingSetBytes": 176758784,
"managedBytesAfterCleanup": 35615200 "managedBytesAfterCleanup": 35608984
}, },
"targets": { "targets": {
"visibleListings": 25000, "visibleListings": 25000,
@@ -39,10 +39,10 @@
{ {
"operation": "registration-and-presence", "operation": "registration-and-presence",
"samples": 1000, "samples": 1000,
"p50Milliseconds": 0.003, "p50Milliseconds": 0.0029,
"p95Milliseconds": 0.0046, "p95Milliseconds": 0.0046,
"p99Milliseconds": 0.0054, "p99Milliseconds": 0.0055,
"operationsPerSecond": 282453.96000451926, "operationsPerSecond": 287918.9220315559,
"minimumOperationsPerSecond": 200, "minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -51,9 +51,9 @@
"operation": "lease-renewal", "operation": "lease-renewal",
"samples": 1000, "samples": 1000,
"p50Milliseconds": 0.0004, "p50Milliseconds": 0.0004,
"p95Milliseconds": 0.0009, "p95Milliseconds": 0.0007,
"p99Milliseconds": 0.0021, "p99Milliseconds": 0.0019,
"operationsPerSecond": 968992.2480620155, "operationsPerSecond": 1076426.264800861,
"minimumOperationsPerSecond": 200, "minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -61,10 +61,10 @@
{ {
"operation": "visible-session-browse", "operation": "visible-session-browse",
"samples": 250, "samples": 250,
"p50Milliseconds": 0.9046, "p50Milliseconds": 1.0232,
"p95Milliseconds": 3.3704, "p95Milliseconds": 3.6083,
"p99Milliseconds": 3.9471, "p99Milliseconds": 4.2925,
"operationsPerSecond": 695.5799787597697, "operationsPerSecond": 650.0325926341947,
"minimumOperationsPerSecond": 200, "minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -72,10 +72,10 @@
{ {
"operation": "join-attempt-issuance", "operation": "join-attempt-issuance",
"samples": 1000, "samples": 1000,
"p50Milliseconds": 0.0029, "p50Milliseconds": 0.0028,
"p95Milliseconds": 0.0045, "p95Milliseconds": 0.0042,
"p99Milliseconds": 0.0055, "p99Milliseconds": 0.0052,
"operationsPerSecond": 296428.042092782, "operationsPerSecond": 135253.93927098127,
"minimumOperationsPerSecond": 200, "minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -83,10 +83,10 @@
{ {
"operation": "simultaneous-punch-pairing", "operation": "simultaneous-punch-pairing",
"samples": 1000, "samples": 1000,
"p50Milliseconds": 0.0043, "p50Milliseconds": 0.0039,
"p95Milliseconds": 0.0073, "p95Milliseconds": 0.0069,
"p99Milliseconds": 0.0115, "p99Milliseconds": 0.0087,
"operationsPerSecond": 109212.03516627532, "operationsPerSecond": 110619.46902654869,
"minimumOperationsPerSecond": 2000, "minimumOperationsPerSecond": 2000,
"budgetMilliseconds": 100, "budgetMilliseconds": 100,
"passed": true "passed": true
@@ -94,10 +94,10 @@
{ {
"operation": "principal-revocation", "operation": "principal-revocation",
"samples": 50, "samples": 50,
"p50Milliseconds": 0.518, "p50Milliseconds": 0.495,
"p95Milliseconds": 0.7049, "p95Milliseconds": 0.6508,
"p99Milliseconds": 11.8557, "p99Milliseconds": 11.011,
"operationsPerSecond": 1320.1773262184577, "operationsPerSecond": 1393.258301729591,
"minimumOperationsPerSecond": 50, "minimumOperationsPerSecond": 50,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -108,7 +108,7 @@
"p50Milliseconds": 0.0001, "p50Milliseconds": 0.0001,
"p95Milliseconds": 0.0001, "p95Milliseconds": 0.0001,
"p99Milliseconds": 0.0001, "p99Milliseconds": 0.0001,
"operationsPerSecond": 1438641.9220256077, "operationsPerSecond": 1479289.9408284025,
"minimumOperationsPerSecond": 10000, "minimumOperationsPerSecond": 10000,
"budgetMilliseconds": 1, "budgetMilliseconds": 1,
"passed": true "passed": true
@@ -116,10 +116,10 @@
{ {
"operation": "coincident-listing-attempt-expiry", "operation": "coincident-listing-attempt-expiry",
"samples": 1, "samples": 1,
"p50Milliseconds": 29.6882, "p50Milliseconds": 27.7056,
"p95Milliseconds": 29.6882, "p95Milliseconds": 27.7056,
"p99Milliseconds": 29.6882, "p99Milliseconds": 27.7056,
"operationsPerSecond": 33.682962483916384, "operationsPerSecond": 36.093525543388026,
"minimumOperationsPerSecond": 0, "minimumOperationsPerSecond": 0,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -134,10 +134,10 @@
"finalReplayMarkers": 0, "finalReplayMarkers": 0,
"expiryChurn": 94906, "expiryChurn": 94906,
"maintenanceSweeps": 36307, "maintenanceSweeps": 36307,
"soakCyclesCompleted": 75126848, "soakCyclesCompleted": 77547145,
"soakDurationSeconds": 300.0000015, "soakDurationSeconds": 300.0000041,
"soakPeakScheduledExpiryEntries": 7, "soakPeakScheduledExpiryEntries": 7,
"soakManagedGrowthBytes": -257288, "soakManagedGrowthBytes": -263432,
"soakHandleGrowth": 2, "soakHandleGrowth": 2,
"restartStartedEmpty": true, "restartStartedEmpty": true,
"overloadWasTyped": true, "overloadWasTyped": 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.
+206 -68
View File
@@ -1,97 +1,235 @@
# Diagnostic TestClient integration guide # Start-to-finish TestClient guide
Tracking: #25 Tracking: #20, #25
`FinalFactory.Rendezvous.TestClient` is the smallest supported public-SDK consumer. `FinalFactory.Rendezvous.TestClient` is the supported executable proof that a
It exists for integration development, CI smoke checks, deployment verification, consumer can publish, browse, authorize, punch, connect, exchange direct traffic,
and operator diagnosis. It is intentionally not a production game client, game and diagnose a failure using only the public Client and Contracts packages. It is
server, matchmaking UI, or relay. intentionally thin: a polished server browser and player-facing connection UI
belong in each game repository.
The automated scenario matrix, privileged Linux namespace run, and topology > **Traversal boundary:** Rendezvous v1 is not a relay and cannot guarantee a
limitations are documented in the [deterministic topology harness](topology-harness.md). > 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.
## Prerequisites 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.
Start a configured Rendezvous service and note both its HTTP base URL and UDP ## First local connection from a clean checkout
mediator endpoint. The host needs a tenant-scoped publisher credential from the
deployment secret boundary. Put it in an environment variable and pass only that Prerequisites are the pinned .NET SDK, Docker with Compose, OpenSSL, Python 3,
variable's name when the default is unsuitable: `curl`, and `jq`. Run these commands from the repository root. The generated key
and credential are disposable local fixtures, not production provisioning.
```bash ```bash
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<deployment-supplied value>' 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
``` ```
Never put the credential in a command argument, URL, checked-in configuration, Only the host terminal needs a publisher credential. Disable shell tracing before
shell trace, or captured test fixture. The development server's signing material capturing it; the helper prints the credential on stdout so command substitution
is process-ephemeral; credentials from a prior development process are invalid. can place it directly in the environment without writing it to disk.
## Manual three-terminal flow
Start the host:
```bash ```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \ set +x
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \ export RENDEZVOUS_PUBLISHER_CREDENTIAL="$(./scripts/mint-local-publisher-credential.sh)"
--game space-game --environment development --region local --protocol 1
``` ```
Browse from another terminal: 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 ```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \ dotnet run --project src/FinalFactory.Rendezvous.TestClient \
browse --service http://127.0.0.1:5000/ \ --configuration Release --no-build -- \
--game space-game --environment development --region local --protocol 1 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
``` ```
Join from a third terminal. Omit `--listing` for an interactive choice: Copy the public listing ID printed by the host, or discover it from terminal 2:
```bash ```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \ dotnet run --project src/FinalFactory.Rendezvous.TestClient \
join --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \ --configuration Release --no-build -- \
--game space-game --environment development --region local --protocol 1 \ browse --service http://127.0.0.1:8080/ \
--listing 00000000-0000-0000-0000-000000000000 --game space-game --environment smoke --region local --protocol 1
``` ```
Replace the sample UUID with the public listing ID printed by host or browse. In terminal 3, either omit `--listing` and select interactively, or provide the
Host and join each create one caller-owned LiteNetLib manager. That same socket copied ID for deterministic selection:
sends presence/punch traffic, establishes the authenticated direct connection,
and carries the ping/echo/ack/completion payload. The final completion confirms
that the host received the reliable acknowledgement; none of this traffic passes through the HTTP
service or UDP mediator.
## CI and deployment smoke flow ```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
```
Use `--script --json`, set `--listing` when deterministic selection matters, and Success means the joiner prints `join.connected` and verified direct traffic,
check the documented process exit code. `--timeout-seconds` bounds each startup, and the host prints verified direct traffic before deregistering. The host and
traversal, or direct-traffic stage; a script host also uses it as its total runtime joiner each create one caller-owned LiteNetLib manager. The same UDP socket sends
unless `--run-seconds` is explicit. A host can add `--exit-after-echo` so it presence and punch traffic, accepts the authenticated peer, and carries the
terminates after the joining peer acknowledges direct traffic and receives the ping/echo/ack/completion payload; direct traffic does not pass through the HTTP
host's completion confirmation. Every wait is service or mediator.
bounded by coordinator state and `--timeout-seconds`; no orchestration should use
an unbounded sleep.
The normal test suite contains a real process gate that starts the built Server, Clean up secrets and the disposable service when finished:
host TestClient, and join TestClient, waits for readiness and versioned events,
and verifies direct traffic, cleanup, JSON shape, and secret canaries. Process
trees are force-terminated in the test cleanup path if normal shutdown fails.
Useful success events are: ```bash
unset RENDEZVOUS_PUBLISHER_CREDENTIAL
docker compose -f deploy/compose/compose.yaml down
rm deploy/compose/secrets/signing-key
```
- `host.registered`, `host.ready`, `host.direct-traffic`, and `host.deregistered`; ## Script and JSON automation
- `browse.completed` and `browse.session`; and
- `join.connected`, `join.direct-traffic`, and `join.outcome-report`.
Failure events preserve stable typed phases and outcomes. When a terminal outcome `--script` forbids prompts and selects the first compatible listing unless
contains a configured dedicated endpoint, `join.fallback` reports `available` `--listing UUID` fixes the choice. `--json` emits one JSON object per line with
with endpoint type `dedicated`; no raw address is printed and no fallback is `version: 1`. New optional properties may be added, but event names and exit
started implicitly. codes are stable automation contracts. Informational events use stdout and
failures use stderr.
## What the proof does and does not establish Successful direct-connection and direct-traffic events include the coarse
`addressFamily` value `ipv4` or `ipv6`. They never include the peer address.
The deterministic loopback test proves the complete service/host/client protocol, The deployment smoke performs the full health, publish, join, mediation, direct
ticket admission, and peer-to-peer payload path. Loopback is not evidence that all traffic, outcome-report, and cleanup flow using bounded waits:
consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths,
or platform policies permit hole punching. Same-LAN, separated observed endpoints, ```bash
network namespaces/containers, mediator restart, and adverse topology coverage dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
belong to the topology harness tracked by #14. Production rollout still requires ./scripts/smoke-deployment.sh
tests from representative networks and a game-owned fallback policy. ```
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.
+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.
+1 -1
View File
@@ -81,7 +81,7 @@ concurrent build, thermal throttling, or oversubscribed CI host.
The checked-in baseline is The checked-in baseline is
[`candidate-2cpu.json`](../evidence/capacity/v2/candidate-2cpu.json). It was [`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 produced on .NET 10.0.9/Linux x64 with CPU affinity restricted to two logical
CPUs. It filled 25,000 listings and 10,000 attempts, peaked at about 162 MiB, 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 and cleared all active/retained state. The five-minute baseline supersedes any
earlier local probe when its timestamp and target duration differ. earlier local probe when its timestamp and target duration differ.
+123
View File
@@ -0,0 +1,123 @@
# Diagnostic dashboards
Tracking: #27
Rendezvous provides two deliberately separate, optional views. The public
session diagnostic helps a player or integration operator understand safe
session-list state using only the public browse contract. The private Grafana
dashboard exposes aggregate operational health through authenticated metrics.
Neither view grants operator privileges or exposes player identity, endpoints,
credentials, capabilities, or raw session metadata beyond the explicitly
allowlisted public browse fields.
## Public read-only session diagnostic
The static diagnostic is disabled by default. Enable it only for approved
game/environment scopes and keep the allowlist narrow:
```json
"Diagnostics": {
"Enabled": true,
"PollIntervalSeconds": 10,
"MaximumRenderedSessions": 100,
"Scopes": [
{
"GameId": "space-game",
"EnvironmentId": "smoke",
"ProtocolVersions": [1, 2],
"Regions": ["local"]
}
]
}
```
Open `/diagnostics` on the same origin as Rendezvous. The page cannot choose a
different backend, request private visibility, join a session, or call the
operator surface. It renders a bounded snapshot, then applies ordered SSE
updates. A replay reset, corrupt cursor, incomplete snapshot, transport failure,
or deliberate reconnect returns to a fresh authoritative snapshot and bounded
polling. Apply filter changes explicitly; **Reset filters** restores the
configured defaults, while **Reconnect now** tests recovery without changing
the selection.
The page uses semantic HTML, labelled controls, visible keyboard focus, status
text in addition to color, a reduced-motion mode, and a 320-pixel reflow. It
creates untrusted content with `textContent` only. The endpoint sets a restrictive
same-origin content-security policy, denies framing, disables MIME sniffing and
browser capabilities, and marks every asset/config response `no-store`.
This is a diagnostics convenience, not a game browser, management console, or
availability monitor. Disable it independently by setting `Enabled` to `false`;
all diagnostic paths then return `404` without affecting game traffic, metrics,
or health endpoints.
## Private Prometheus and Grafana view
The observability overlay pins Prometheus 3.13.1 and Grafana 13.1.0 by immutable
multi-platform image digest. Prometheus is not published to the host. Grafana is
bound to host loopback, disables anonymous access and sign-up, and reads its
administrator password from a file. The service and Prometheus share only the
metrics bearer-token file. All three secrets remain ignored by Git.
Create independent random secrets, then start the base service and overlay:
```bash
install -d -m 0700 deploy/compose/secrets deploy/observability/secrets
umask 077
openssl rand -out deploy/compose/secrets/signing-key 32
openssl rand -hex 32 >deploy/observability/secrets/rendezvous-metrics-token
openssl rand -base64 36 >deploy/observability/secrets/grafana-admin-password
export RENDEZVOUS_UID="$(id -u)"
export RENDEZVOUS_GID="$(id -g)"
test "$RENDEZVOUS_UID" -ne 0
docker compose \
-f deploy/compose/compose.yaml \
-f deploy/observability/compose.yaml \
up --build --detach
```
Visit `http://127.0.0.1:3000`, sign in as `rendezvous-admin`, and open the
**Rendezvous operational overview** folder/dashboard. The provisioned panels
cover scrape/store/drain health, listing and join capacity, HTTP volume/errors
and p95, browse/SSE load, lease and join operations, UDP results/latency/bytes,
admission drops, connection outcomes, pairing latency, presence/expiry state,
signing windows, security/audit results, and process/GC/descriptor pressure.
Capacity gauges use the approved single-process envelope of 25,000 listings and
10,000 active attempts, with 70% warning and 90% critical thresholds. The UDP
response series is a conservative admitted maximum, not observed egress.
Validate merged configuration and checked-in dashboard structure before every
rollout:
```bash
RENDEZVOUS_UID="$(id -u)" RENDEZVOUS_GID="$(id -g)" \
docker compose \
-f deploy/compose/compose.yaml \
-f deploy/observability/compose.yaml \
config --quiet
./scripts/test-observability-assets.sh
```
For a real deployment, keep Grafana on a private authenticated management
network instead of host loopback, replace the local admin login with the
organization's supported identity boundary, enforce TLS at the edge, and set
retention to the approved operational period. Do not make Prometheus public.
Provisioning is read-only so local UI edits cannot silently drift from source.
## Verify, rotate, and disable
After startup, verify the dashboard shows `UP`, store `AVAILABLE`, a nonzero
signing window, and changing request/UDP panels during a smoke run. Confirm an
unauthenticated `/metrics` request returns `404`, the bearer-authenticated
collector target is healthy, Prometheus is not bound on a host port, Grafana is
not anonymously accessible, and dashboard query labels contain no identifiers.
Rotate metrics access by writing a new 32-128 character token to the secret file
with private permissions and restarting Rendezvous and Prometheus together.
Rotate the Grafana administrator password through the same protected secret
workflow. Delete both secret files after a disposable local run.
To disable aggregate observability independently, stop/remove the overlay and
set `Rendezvous:Metrics:Enabled` to `false`; `/metrics` returns `404` and the
core service continues. To disable only the public session diagnostic, leave the
overlay running and set `Rendezvous:Diagnostics:Enabled` to `false`.
+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.
@@ -1,10 +1,15 @@
# Observability and operator runbook # Observability and operator reference
This runbook defines the production signals and privileged controls for the This runbook defines the production signals and privileged controls for the
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
from the `FinalFactory.Rendezvous` meter and distributed-tracing activities from from the `FinalFactory.Rendezvous` meter, distributed-tracing activities from
`FinalFactory.Rendezvous.Server`. Connect those sources to the deployment's `FinalFactory.Rendezvous.Server`, and an optional bearer-protected Prometheus
OpenTelemetry or equivalent collector. Do not add identifiers to metric labels. endpoint. Connect only a private collector network. 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 ## Health and readiness
@@ -34,6 +39,25 @@ OpenTelemetry or equivalent collector. Do not add identifiers to metric labels.
| `rendezvous.store.expiry_churn` | Cumulative natural expiry activity | none | | `rendezvous.store.expiry_churn` | Cumulative natural expiry activity | none |
| `rendezvous.store.available` | Store health (`1` available, `0` unavailable) | none | | `rendezvous.store.available` | Store health (`1` available, `0` unavailable) | none |
The Prometheus exporter additionally exposes active/fresh/awaiting store state,
drain state, SSE subscriber/tenant/replay gauges, bounded HTTP and UDP
histograms, UDP ingress and conservative admitted-response budgets, signing-key
state/window gauges, and process/.NET pressure. Its only labels are the fixed
operation, status, result, transport, partition, action, outcome, elapsed-bucket,
key-state, and GC-generation dimensions. Unknown or unsafe values normalize to
`other`; identifiers, metadata, addresses, endpoints, tokens, and capabilities
are never labels.
The exporter is disabled by default. Enabling `Rendezvous:Metrics:Enabled`
requires `BearerTokenSecretReference` to be an external `env:` or absolute
`file:` secret containing 32-128 visible ASCII bytes. Unauthorized requests get
the same `404` as a disabled endpoint, and accepted responses are `no-store`.
Expose `/metrics` only to the private collector network, rotate its token as a
deployment secret, and never place the token in a URL, Compose environment
value, dashboard, log, or issue. The checked-in Prometheus/Grafana provisioning
and its verification procedure are in
[diagnostic dashboards](diagnostic-dashboards.md).
HTTP responses include `X-Rendezvous-Correlation-ID`. It is a generated trace ID 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 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 HTTP activities contain operation-level data only. Logs and traces must not add
+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"
}
}
+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"
}
]
}
+17
View File
@@ -0,0 +1,17 @@
# 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/runtime:8.0.28-noble@sha256:19a311642eb7ee9c985bd71b9e5618123879df4e8d948f7388a41b0ee4788e25 AS dotnet-runtime-8
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
COPY --from=dotnet-runtime-8 /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.28/ /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.28/
RUN dotnet --version \
&& dotnet --list-runtimes \
&& 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"
+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"
+37 -46
View File
@@ -13,7 +13,7 @@ ENVIRONMENT_ID="${RENDEZVOUS_SMOKE_ENVIRONMENT_ID:-smoke}"
REGION="${RENDEZVOUS_SMOKE_REGION:-local}" REGION="${RENDEZVOUS_SMOKE_REGION:-local}"
PROTOCOL_VERSION="${RENDEZVOUS_SMOKE_PROTOCOL_VERSION:-1}" PROTOCOL_VERSION="${RENDEZVOUS_SMOKE_PROTOCOL_VERSION:-1}"
for command in curl date dotnet jq mktemp od openssl tail tr wc; do for command in curl dotnet jq mktemp tail; do
command -v "$command" >/dev/null || { command -v "$command" >/dev/null || {
printf 'Missing required command: %s\n' "$command" >&2 printf 'Missing required command: %s\n' "$command" >&2
exit 2 exit 2
@@ -35,39 +35,14 @@ for scoped_value in "$GAME_ID" "$ENVIRONMENT_ID" "$REGION"; do
fi fi
done done
base64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
local_credential() { local_credential() {
if [[ ! -f "$LOCAL_KEY" ]] || [[ "$(wc -c < "$LOCAL_KEY")" -ne 32 ]]; then if [[ "$GAME_ID" != space-game || "$ENVIRONMENT_ID" != smoke \
printf 'Local Compose smoke key must be exactly 32 bytes: %s\n' "$LOCAL_KEY" >&2 || "$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 exit 2
fi fi
RENDEZVOUS_SMOKE_LOCAL_KEY="$LOCAL_KEY" \
local now expires nonce payload encoded signed hex signature "$ROOT/scripts/mint-local-publisher-credential.sh"
now="$(date +%s)"
expires="$((now + 600))"
nonce="$(openssl rand -hex 16)"
payload="$(jq -cn \
--arg issuer final-factory-rendezvous-smoke \
--arg audience rendezvous-service \
--arg subject local-smoke-host \
--arg kind dedicatedPublisher \
--arg gameId "$GAME_ID" \
--arg environmentId "$ENVIRONMENT_ID" \
--arg region "$REGION" \
--arg nonce "$nonce" \
--argjson now "$now" \
--argjson expires "$expires" \
'{version:1,issuer:$issuer,audience:$audience,subject:$subject,kind:$kind,gameId:$gameId,environmentId:$environmentId,regions:[$region],permissions:[],issuedAtUnixSeconds:$now,notBeforeUnixSeconds:$now,expiresAtUnixSeconds:$expires,nonce:$nonce}')"
encoded="$(printf '%s' "$payload" | base64url)"
signed="rv1.local-smoke-1.$encoded"
hex="$(od -An -v -tx1 "$LOCAL_KEY" | tr -d ' \n')"
signature="$(printf '%s' "$signed" \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$hex" -binary \
| base64url)"
printf '%s.%s' "$signed" "$signature"
} }
credential="${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}" credential="${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}"
@@ -83,6 +58,14 @@ temp_dir="$(mktemp -d)"
host_log="$temp_dir/host.jsonl" host_log="$temp_dir/host.jsonl"
join_log="$temp_dir/join.jsonl" join_log="$temp_dir/join.jsonl"
host_pid='' host_pid=''
sanitize_log() {
jq -Rrc 'fromjson? | {
event: (.event // "unknown"),
status: (.status // "unknown"),
phase: (.phase // "unknown"),
hasListingId: (((.listingId // "") | length) > 0)
}' "$1"
}
cleanup() { cleanup() {
local status="$?" local status="$?"
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
@@ -91,8 +74,8 @@ cleanup() {
fi fi
if [[ "$status" -ne 0 ]]; then if [[ "$status" -ne 0 ]]; then
printf 'Deployment smoke failed; sanitized diagnostic events follow.\n' >&2 printf 'Deployment smoke failed; sanitized diagnostic events follow.\n' >&2
[[ -f "$host_log" ]] && jq -c . "$host_log" >&2 || true [[ -f "$host_log" ]] && sanitize_log "$host_log" >&2 || true
[[ -f "$join_log" ]] && jq -c . "$join_log" >&2 || true [[ -f "$join_log" ]] && sanitize_log "$join_log" >&2 || true
fi fi
rm -rf "$temp_dir" rm -rf "$temp_dir"
return "$status" return "$status"
@@ -109,26 +92,24 @@ dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-buil
host_pid="$!" host_pid="$!"
ready=false ready=false
listing_id=''
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
if jq -e 'select(.event == "host.ready")' "$host_log" >/dev/null 2>&1; then if listing_id="$(jq -er '
select(.event == "host.ready" and .status == "ready")
| .listingId // empty
' "$host_log" 2>/dev/null | tail -n 1)" && [[ -n "$listing_id" ]]; then
ready=true ready=true
break break
fi fi
if ! kill -0 "$host_pid" 2>/dev/null; then if ! kill -0 "$host_pid" 2>/dev/null; then
printf 'Host diagnostic stopped before it became ready.\n' >&2 printf 'Host diagnostic stopped before it became ready.\n' >&2
jq -c . "$host_log" >&2 || true sanitize_log "$host_log" >&2 || true
exit 1 exit 1
fi fi
sleep 0.25 sleep 0.25
done done
if [[ "$ready" != true ]]; then if [[ "$ready" != true ]]; then
printf 'Host diagnostic did not become ready within %s seconds.\n' "$TIMEOUT_SECONDS" >&2 printf 'Host diagnostic did not report a ready listing 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 exit 1
fi fi
@@ -140,9 +121,19 @@ dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-buil
wait "$host_pid" wait "$host_pid"
host_pid='' host_pid=''
jq -e 'select(.event == "host.direct-traffic" and .status == "verified")' "$host_log" >/dev/null if ! jq -s -e '
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$host_log" >/dev/null any(.[]; .event == "host.direct-traffic" and .status == "verified")
jq -e 'select(.event == "join.direct-traffic" and .status == "verified")' "$join_log" >/dev/null and any(.[]; .event == "host.deregistered" and .status == "complete")
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$join_log" >/dev/null ' "$host_log" >/dev/null; then
printf 'Host diagnostic did not complete authenticated traffic and deregistration.\n' >&2
exit 1
fi
if ! jq -s -e '
any(.[]; .event == "join.direct-traffic" and .status == "verified")
and any(.[]; .event == "join.outcome-report" and .status == "accepted")
' "$join_log" >/dev/null; then
printf 'Join diagnostic did not complete authenticated traffic and outcome reporting.\n' >&2
exit 1
fi
printf 'Rendezvous deployment smoke passed: HTTP live/ready and authenticated UDP mediation/direct traffic.\n' printf 'Rendezvous deployment smoke passed: HTTP live/ready and authenticated UDP mediation/direct traffic.\n'
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
node --test tests/Diagnostics/*.test.mjs
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
node --test tests/Observability/observability-assets.test.mjs
+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"
@@ -7,10 +7,12 @@
<PackageId>FinalFactory.Rendezvous.Client</PackageId> <PackageId>FinalFactory.Rendezvous.Client</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description> <Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
<PackageTags>final-factory;multiplayer;nat;godot;litenetlib</PackageTags>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" /> <ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" /> <PackageReference Include="LiteNetLib" />
<None Update="README.md" Pack="true" PackagePath="\" /> <None Update="README.md" Pack="true" PackagePath="\" />
<None Include="../../CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -67,12 +67,17 @@ factory does not open a socket, and synchronized events must remain enabled:
```csharp ```csharp
RendezvousNetListener networkEvents = new(); RendezvousNetListener networkEvents = new();
NetManager gameplayNetManager = networkEvents.CreateManager(); NetManager gameplayNetManager = networkEvents.CreateManager();
gameplayNetManager.ChannelsCount = 3; // example: configure the game protocol first
if (!gameplayNetManager.Start(0)) if (!gameplayNetManager.Start(0))
{ {
throw new InvalidOperationException("The gameplay UDP socket could not start."); 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 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 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 LiteNetLib and dispatches its synchronized callbacks. Call it once per game
@@ -144,6 +144,11 @@ public interface IRendezvousSessionBrowserClient
EnvironmentId environmentId, EnvironmentId environmentId,
uint protocolVersion, uint protocolVersion,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
BrowseSessionsRequest request,
string streamCursor,
CancellationToken cancellationToken = default);
} }
public interface IRendezvousJoinClient public interface IRendezvousJoinClient
@@ -114,6 +114,49 @@ internal sealed class RendezvousHttpTransport
} }
} }
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>( internal static HttpRequestMessage JsonRequest<T>(
HttpMethod method, HttpMethod method,
string uri, string uri,
@@ -84,6 +84,9 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
{ {
ContractVersion = request.ContractVersion, ContractVersion = request.ContractVersion,
LeaseToken = session.LeaseToken, LeaseToken = session.LeaseToken,
RegionId = request.RegionId,
ProtocolVersion = request.ProtocolVersion,
Visibility = request.Visibility,
BuildVersion = request.BuildVersion, BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName, DisplayName = request.DisplayName,
Capacity = CopyCapacity(request.Capacity), Capacity = CopyCapacity(request.Capacity),
@@ -1,3 +1,7 @@
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client; namespace FinalFactory.Rendezvous.Client;
@@ -106,5 +110,264 @@ public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserCl
cancellationToken); 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 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);
}
}
} }
@@ -4,7 +4,7 @@
".NETStandard,Version=v2.1": { ".NETStandard,Version=v2.1": {
"LiteNetLib": { "LiteNetLib": {
"type": "Direct", "type": "Direct",
"requested": "[2.1.4, )", "requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4", "resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA==" "contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
}, },
@@ -5,6 +5,7 @@ public static class ContractLimits
public const int ContractVersion = 1; public const int ContractVersion = 1;
public const int HttpRequestMaxBytes = 16 * 1024; public const int HttpRequestMaxBytes = 16 * 1024;
public const int BrowserResponseMaxBytes = 256 * 1024; public const int BrowserResponseMaxBytes = 256 * 1024;
public const int SessionStreamEventMaxBytes = 32 * 1024;
public const int UdpDatagramMaxBytes = 1_200; public const int UdpDatagramMaxBytes = 1_200;
public const int MetadataMaxBytes = 4 * 1024; public const int MetadataMaxBytes = 4 * 1024;
public const int MetadataMaxKeys = 32; public const int MetadataMaxKeys = 32;
@@ -5,9 +5,13 @@
<RootNamespace>FinalFactory.Rendezvous.Contracts</RootNamespace> <RootNamespace>FinalFactory.Rendezvous.Contracts</RootNamespace>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
<PackageId>FinalFactory.Rendezvous.Contracts</PackageId> <PackageId>FinalFactory.Rendezvous.Contracts</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Versioned transport-neutral contracts for Final Factory Rendezvous.</Description> <Description>Versioned transport-neutral contracts for Final Factory Rendezvous.</Description>
<PackageTags>final-factory;multiplayer;contracts;godot</PackageTags>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="System.Text.Json" /> <PackageReference Include="System.Text.Json" />
<None Update="README.md" Pack="true" PackagePath="\" />
<None Include="../../CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -140,6 +140,10 @@ public sealed class UpdateSessionRequest
[JsonRequired] [JsonRequired]
public string LeaseToken { get; set; } = string.Empty; public string LeaseToken { get; set; } = string.Empty;
public RegionId? RegionId { get; set; }
public uint? ProtocolVersion { get; set; }
public ListingVisibility? Visibility { get; set; }
[JsonRequired] [JsonRequired]
public string BuildVersion { get; set; } = string.Empty; public string BuildVersion { get; set; } = string.Empty;
@@ -194,6 +198,32 @@ public sealed class BrowseSessionsResponse
public List<SessionListing> Items { get; set; } = []; public List<SessionListing> Items { get; set; } = [];
public string? NextCursor { 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 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.
@@ -12,6 +12,8 @@ internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Val
internal sealed class SessionBrowserService( internal sealed class SessionBrowserService(
IEphemeralRendezvousStore store, IEphemeralRendezvousStore store,
SessionBrowserCursorCodec cursors, SessionBrowserCursorCodec cursors,
SessionStreamCursorCodec streamCursors,
SessionChangeJournal changes,
IWallClock clock) IWallClock clock)
{ {
public BrowserServiceResult<BrowseSessionsResponse> Browse( public BrowserServiceResult<BrowseSessionsResponse> Browse(
@@ -45,9 +47,25 @@ internal sealed class SessionBrowserService(
request.PageSize + 1, request.PageSize + 1,
after, after,
request.ExcludeFull); request.ExcludeFull);
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings( StoreResult<IReadOnlyList<StoredListing>> found = default!;
query, long streamRevision = 0;
cancellationToken); bool stableSnapshot = false;
for (int attempt = 0; attempt < 3; attempt++)
{
long before = changes.CurrentRevision;
found = store.BrowseVisibleListings(query, cancellationToken);
long afterRevision = changes.CurrentRevision;
if (before == afterRevision)
{
streamRevision = afterRevision;
stableSnapshot = true;
break;
}
}
if (!stableSnapshot)
{
return new(RendezvousErrorCode.ServiceUnavailable);
}
if (!found.Succeeded || found.Value is null) if (!found.Succeeded || found.Value is null)
{ {
return new(found.Code == StoreResultCode.ServiceUnavailable return new(found.Code == StoreResultCode.ServiceUnavailable
@@ -65,7 +83,12 @@ internal sealed class SessionBrowserService(
string? nextCursor = hasMore string? nextCursor = hasMore
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow) ? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
: null; : null;
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor }; BrowseSessionsResponse response = new()
{
Items = items,
NextCursor = nextCursor,
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
};
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
<= ContractLimits.BrowserResponseMaxBytes) <= ContractLimits.BrowserResponseMaxBytes)
{ {
@@ -76,7 +99,10 @@ internal sealed class SessionBrowserService(
hasMore = true; hasMore = true;
} }
return new(RendezvousErrorCode.None, new BrowseSessionsResponse()); return new(RendezvousErrorCode.None, new BrowseSessionsResponse
{
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
});
} }
public BrowserServiceResult<GetSessionResponse> Get( public BrowserServiceResult<GetSessionResponse> Get(
@@ -0,0 +1,309 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed record SessionChangeJournalOptions
{
public int ReplayCapacity { get; init; } = 4096;
public int MaximumSubscribers { get; init; } = 256;
public int MaximumSubscribersPerTenant { get; init; } = 64;
public int MaximumBatchSize { get; init; } = 128;
public TimeSpan CoalesceInterval { get; init; } = TimeSpan.FromMilliseconds(50);
public TimeSpan KeepaliveInterval { get; init; } = TimeSpan.FromSeconds(15);
public TimeSpan MaximumConnectionDuration { get; init; } = TimeSpan.FromMinutes(5);
public void Validate()
{
if (ReplayCapacity is < 64 or > 65_536
|| MaximumSubscribers is < 1 or > 4096
|| MaximumSubscribersPerTenant < 1
|| MaximumSubscribersPerTenant > MaximumSubscribers
|| MaximumBatchSize is < 1 or > 1024
|| CoalesceInterval < TimeSpan.Zero
|| CoalesceInterval > TimeSpan.FromSeconds(1)
|| KeepaliveInterval < TimeSpan.FromSeconds(1)
|| KeepaliveInterval > TimeSpan.FromMinutes(1)
|| MaximumConnectionDuration < KeepaliveInterval
|| MaximumConnectionDuration > TimeSpan.FromMinutes(30))
{
throw new ArgumentOutOfRangeException(nameof(SessionChangeJournalOptions));
}
}
}
internal sealed record SessionChange(
long Revision,
SessionListingProjection? Before,
SessionListingProjection? After)
{
public SessionListingId ListingId => (After ?? Before)!.Listing.ListingId;
}
internal sealed record SessionChangeBatch(
long CurrentRevision,
bool RequiresReset,
IReadOnlyList<SessionChange> Changes);
internal sealed class SessionListingProjection
{
private SessionListingProjection(SessionListing listing, bool visible)
{
Listing = listing;
Visible = visible;
}
public SessionListing Listing { get; }
public bool Visible { get; }
public static SessionListingProjection From(StoredListing stored) => new(
new SessionListing
{
ListingId = stored.Definition.ListingId,
GameId = stored.Definition.Scope.GameId,
EnvironmentId = stored.Definition.Scope.EnvironmentId,
RegionId = stored.Definition.RegionId,
ProtocolVersion = stored.Definition.ProtocolVersion,
BuildVersion = stored.Definition.BuildVersion,
DisplayName = stored.Definition.DisplayName,
Visibility = stored.Definition.Visibility,
PublisherTrustMode = stored.Definition.TrustMode,
Capacity = new SessionCapacity
{
CurrentPlayers = stored.Definition.CurrentPlayers,
MaximumPlayers = stored.Definition.MaximumPlayers,
},
Metadata = new Dictionary<string, string>(stored.Definition.Metadata, StringComparer.Ordinal),
DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback),
},
stored.HasFreshPresence && stored.Definition.Visibility == ListingVisibility.Public);
public bool Matches(VisibleListingQuery query) => Visible
&& Listing.GameId == query.Scope.GameId
&& Listing.EnvironmentId == query.Scope.EnvironmentId
&& Listing.ProtocolVersion == query.ProtocolVersion
&& (!query.RegionId.HasValue || Listing.RegionId == query.RegionId.Value)
&& (!query.ExcludeFull
|| Listing.Capacity.CurrentPlayers < Listing.Capacity.MaximumPlayers);
public static bool Equivalent(SessionListingProjection? left, SessionListingProjection? right)
{
if (ReferenceEquals(left, right))
{
return true;
}
if (left is null || right is null || left.Visible != right.Visible)
{
return false;
}
SessionListing a = left.Listing;
SessionListing b = right.Listing;
return a.ListingId == b.ListingId
&& a.GameId == b.GameId
&& a.EnvironmentId == b.EnvironmentId
&& a.RegionId == b.RegionId
&& a.ProtocolVersion == b.ProtocolVersion
&& string.Equals(a.BuildVersion, b.BuildVersion, StringComparison.Ordinal)
&& string.Equals(a.DisplayName, b.DisplayName, StringComparison.Ordinal)
&& a.Visibility == b.Visibility
&& a.PublisherTrustMode == b.PublisherTrustMode
&& a.Capacity.CurrentPlayers == b.Capacity.CurrentPlayers
&& a.Capacity.MaximumPlayers == b.Capacity.MaximumPlayers
&& a.Metadata.Count == b.Metadata.Count
&& a.Metadata.All(item => b.Metadata.TryGetValue(item.Key, out string? value)
&& string.Equals(item.Value, value, StringComparison.Ordinal))
&& EndpointEquals(a.DedicatedFallback, b.DedicatedFallback);
}
private static bool EndpointEquals(NetworkEndpoint? left, NetworkEndpoint? right) =>
left is null && right is null
|| left is not null && right is not null
&& left.AddressFamily == right.AddressFamily
&& string.Equals(left.Address, right.Address, StringComparison.Ordinal)
&& left.Port == right.Port;
}
internal sealed class SessionChangeJournal
{
private readonly object _gate = new();
private readonly SessionChangeJournalOptions _options;
private readonly Queue<SessionChange> _changes = [];
private TaskCompletionSource<long> _changed = NewSignal();
private long _revision;
private int _subscribers;
private readonly Dictionary<TenantScope, int> _subscribersByTenant = [];
public SessionChangeJournal(SessionChangeJournalOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
_options = options;
}
public SessionChangeJournalOptions Options => _options;
public int ReplayCount
{
get
{
lock (_gate)
{
return _changes.Count;
}
}
}
public int SubscriberCount
{
get
{
lock (_gate)
{
return _subscribers;
}
}
}
public int SubscribedTenantCount
{
get
{
lock (_gate)
{
return _subscribersByTenant.Count;
}
}
}
public long CurrentRevision
{
get
{
lock (_gate)
{
return _revision;
}
}
}
public void Publish(StoredListing? before, StoredListing? after)
{
SessionListingProjection? previous = before is null ? null : SessionListingProjection.From(before);
SessionListingProjection? current = after is null ? null : SessionListingProjection.From(after);
if (SessionListingProjection.Equivalent(previous, current)
|| previous is { Visible: false } && current is null
|| previous is null && current is { Visible: false })
{
return;
}
TaskCompletionSource<long> signal;
long revision;
lock (_gate)
{
revision = ++_revision;
_changes.Enqueue(new SessionChange(revision, previous, current));
while (_changes.Count > _options.ReplayCapacity)
{
_changes.Dequeue();
}
signal = _changed;
_changed = NewSignal();
}
signal.TrySetResult(revision);
}
public SessionChangeBatch ReadAfter(long revision)
{
lock (_gate)
{
long oldest = _changes.TryPeek(out SessionChange? first)
? first.Revision
: _revision + 1;
if (revision < oldest - 1 || revision > _revision)
{
return new(_revision, true, []);
}
SessionChange[] changes = _changes
.Where(change => change.Revision > revision)
.Take(_options.MaximumBatchSize)
.ToArray();
return new(_revision, false, changes);
}
}
public async Task<bool> WaitForChangeAsync(
long revision,
TimeSpan timeout,
CancellationToken cancellationToken)
{
Task<long> signal;
lock (_gate)
{
if (_revision > revision)
{
return true;
}
signal = _changed.Task;
}
try
{
await signal.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
return true;
}
catch (TimeoutException)
{
return false;
}
}
public bool TrySubscribe(TenantScope scope, out IDisposable? lease)
{
lock (_gate)
{
if (_subscribers >= _options.MaximumSubscribers
|| _subscribersByTenant.GetValueOrDefault(scope)
>= _options.MaximumSubscribersPerTenant)
{
lease = null;
return false;
}
_subscribers++;
_subscribersByTenant[scope] = _subscribersByTenant.GetValueOrDefault(scope) + 1;
lease = new Subscription(this, scope);
return true;
}
}
private void Release(TenantScope scope)
{
lock (_gate)
{
_subscribers--;
int remaining = _subscribersByTenant[scope] - 1;
if (remaining == 0)
{
_subscribersByTenant.Remove(scope);
}
else
{
_subscribersByTenant[scope] = remaining;
}
}
}
private static TaskCompletionSource<long> NewSignal() => new(
TaskCreationOptions.RunContinuationsAsynchronously);
private sealed class Subscription(
SessionChangeJournal owner,
TenantScope scope) : IDisposable
{
private SessionChangeJournal? _owner = owner;
public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Release(scope);
}
}
@@ -0,0 +1,108 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed class SessionStreamCursorCodec : IDisposable
{
private const string Prefix = "rvs1";
private readonly EphemeralCursorProtector _protector = new();
public string Encode(VisibleListingQuery query, long revision, DateTimeOffset now)
{
ArgumentOutOfRangeException.ThrowIfNegative(revision);
SessionStreamCursorPayload payload = new()
{
GameId = query.Scope.GameId.Value,
EnvironmentId = query.Scope.EnvironmentId.Value,
ProtocolVersion = query.ProtocolVersion,
RegionId = query.RegionId?.Value,
ExcludeFull = query.ExcludeFull,
Revision = revision,
ExpiresAtUnixSeconds = now.AddMinutes(10).ToUnixTimeSeconds(),
};
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
try
{
return _protector.Protect(Prefix, encoded);
}
finally
{
CryptographicOperations.ZeroMemory(encoded);
}
}
public bool TryDecode(
string? cursor,
VisibleListingQuery query,
DateTimeOffset now,
out long revision)
{
revision = 0;
if (cursor is null || !_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
{
return false;
}
SessionStreamCursorPayload? payload;
try
{
payload = JsonSerializer.Deserialize<SessionStreamCursorPayload>(
encodedPayload,
ContractJson.Options);
}
catch (JsonException)
{
payload = null;
}
finally
{
CryptographicOperations.ZeroMemory(encodedPayload);
}
if (payload is null
|| payload.Revision < 0
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|| !string.Equals(payload.GameId, query.Scope.GameId.Value, StringComparison.Ordinal)
|| !string.Equals(payload.EnvironmentId, query.Scope.EnvironmentId.Value, StringComparison.Ordinal)
|| payload.ProtocolVersion != query.ProtocolVersion
|| !string.Equals(payload.RegionId, query.RegionId?.Value, StringComparison.Ordinal)
|| payload.ExcludeFull != query.ExcludeFull)
{
return false;
}
revision = payload.Revision;
return true;
}
public void Dispose() => _protector.Dispose();
public override string ToString() => "[SessionStreamCursorCodec: key and cursors redacted]";
}
internal sealed class SessionStreamCursorPayload
{
[JsonRequired]
public string GameId { get; set; } = string.Empty;
[JsonRequired]
public string EnvironmentId { get; set; } = string.Empty;
[JsonRequired]
public uint ProtocolVersion { get; set; }
public string? RegionId { get; set; }
[JsonRequired]
public bool ExcludeFull { get; set; }
[JsonRequired]
public long Revision { get; set; }
[JsonRequired]
public long ExpiresAtUnixSeconds { get; set; }
}
@@ -0,0 +1,172 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed record SessionStreamReadResult(
bool RequiresReset,
IReadOnlyList<SessionStreamEvent> Events);
internal sealed class SessionStreamSubscription : IDisposable
{
private IDisposable? _lease;
public SessionStreamSubscription(
VisibleListingQuery query,
long revision,
bool requiresReset,
IDisposable lease)
{
Query = query;
Revision = revision;
RequiresReset = requiresReset;
_lease = lease;
}
public VisibleListingQuery Query { get; }
public long Revision { get; set; }
public bool RequiresReset { get; set; }
public void Dispose() => Interlocked.Exchange(ref _lease, null)?.Dispose();
}
internal sealed class SessionStreamService(
SessionChangeJournal changes,
SessionStreamCursorCodec cursors,
IWallClock clock)
{
public BrowserServiceResult<SessionStreamSubscription> Subscribe(
BrowseSessionsRequest request,
string? cursor)
{
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = Validate(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
VisibleListingQuery query = new(
new TenantScope(request.GameId, request.EnvironmentId),
request.ProtocolVersion,
request.RegionId,
ContractLimits.BrowserPageMaxItems,
ExcludeFull: request.ExcludeFull);
if (!changes.TrySubscribe(query.Scope, out IDisposable? lease) || lease is null)
{
return new(RendezvousErrorCode.CapacityExceeded);
}
bool validCursor = cursors.TryDecode(cursor, query, clock.UtcNow, out long revision);
if (!validCursor)
{
revision = changes.CurrentRevision;
}
return new(RendezvousErrorCode.None, new SessionStreamSubscription(
query,
revision,
requiresReset: !validCursor,
lease));
}
public SessionStreamReadResult Read(SessionStreamSubscription subscription)
{
ArgumentNullException.ThrowIfNull(subscription);
if (subscription.RequiresReset)
{
subscription.RequiresReset = false;
return new(true, []);
}
SessionChangeBatch batch = changes.ReadAfter(subscription.Revision);
if (batch.RequiresReset)
{
subscription.Revision = batch.CurrentRevision;
return new(true, []);
}
if (batch.Changes.Count == 0)
{
return new(false, []);
}
Dictionary<SessionListingId, PendingDelta> coalesced = [];
foreach (SessionChange change in batch.Changes)
{
bool beforeMatches = change.Before?.Matches(subscription.Query) == true;
bool afterMatches = change.After?.Matches(subscription.Query) == true;
if (!beforeMatches && !afterMatches)
{
continue;
}
coalesced[change.ListingId] = afterMatches
? new(change.Revision, SessionStreamEventKind.SessionUpsert, change.After!.Listing)
: new(change.Revision, SessionStreamEventKind.SessionRemove, null);
}
subscription.Revision = batch.Changes[^1].Revision;
SessionStreamEvent[] events = coalesced
.OrderBy(static item => item.Value.Revision)
.Select(item => ToEvent(item.Key, item.Value, subscription.Query))
.ToArray();
return new(false, events);
}
public async Task<bool> WaitForChangeAsync(
SessionStreamSubscription subscription,
CancellationToken cancellationToken)
{
bool changed = await changes.WaitForChangeAsync(
subscription.Revision,
changes.Options.KeepaliveInterval,
cancellationToken).ConfigureAwait(false);
if (changed && changes.Options.CoalesceInterval > TimeSpan.Zero)
{
await Task.Delay(changes.Options.CoalesceInterval, cancellationToken)
.ConfigureAwait(false);
}
return changed;
}
public SessionStreamEvent ResetEvent(SessionStreamSubscription subscription) => new()
{
Kind = SessionStreamEventKind.Reset,
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
};
public SessionStreamEvent KeepaliveEvent(SessionStreamSubscription subscription) => new()
{
Kind = SessionStreamEventKind.Keepalive,
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
};
public TimeSpan MaximumConnectionDuration => changes.Options.MaximumConnectionDuration;
private SessionStreamEvent ToEvent(
SessionListingId listingId,
PendingDelta delta,
VisibleListingQuery query) => new()
{
Kind = delta.Kind,
Cursor = cursors.Encode(query, delta.Revision, clock.UtcNow),
Session = delta.Session,
ListingId = delta.Kind == SessionStreamEventKind.SessionRemove ? listingId : null,
};
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return string.IsNullOrEmpty(request.GameId.Value)
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|| request.ProtocolVersion == 0
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private sealed record PendingDelta(
long Revision,
SessionStreamEventKind Kind,
SessionListing? Session);
}
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Deployment; namespace FinalFactory.Rendezvous.Server.Deployment;
internal sealed partial class GracefulDrainService : IHostedService, IDisposable internal sealed class GracefulDrainService : IHostedService, IDisposable
{ {
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
private readonly InMemoryEphemeralRendezvousStore _store; private readonly InMemoryEphemeralRendezvousStore _store;
@@ -84,15 +84,19 @@ internal sealed partial class GracefulDrainService : IHostedService, IDisposable
} }
} }
[LoggerMessage( private static readonly Action<ILogger, int, Exception?> DrainStarted = LoggerMessage.Define<int>(
EventId = 1, LogLevel.Information,
Level = LogLevel.Information, new EventId(1, nameof(LogDrainStarted)),
Message = "Graceful drain started with a {DrainDeadlineSeconds}-second deadline")] "Graceful drain started with a {DrainDeadlineSeconds}-second deadline");
private static partial void LogDrainStarted(ILogger logger, int drainDeadlineSeconds);
[LoggerMessage( private static readonly Action<ILogger, double, Exception?> DrainFinished = LoggerMessage.Define<double>(
EventId = 2, LogLevel.Information,
Level = LogLevel.Information, new EventId(2, nameof(LogDrainFinished)),
Message = "Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared")] "Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared");
private static partial void LogDrainFinished(ILogger logger, double elapsedMilliseconds);
private static void LogDrainStarted(ILogger logger, int drainDeadlineSeconds) =>
DrainStarted(logger, drainDeadlineSeconds, null);
private static void LogDrainFinished(ILogger logger, double elapsedMilliseconds) =>
DrainFinished(logger, elapsedMilliseconds, null);
} }
@@ -0,0 +1,615 @@
const CONTRACT_VERSION = 1;
const BROWSER_PAGE_LIMIT = 100;
const REQUEST_TIMEOUT_MS = 10_000;
const STREAM_FAILURE_LIMIT = 3;
export class SessionProjection {
constructor(maximumSessions = 100) {
if (!Number.isInteger(maximumSessions) || maximumSessions < 1 || maximumSessions > 500) {
throw new RangeError("maximumSessions must be between 1 and 500");
}
this.maximumSessions = maximumSessions;
this.entries = new Map();
this.cursor = "";
this.hasMore = false;
}
replace(items, cursor, hasMore = false, updatedAt = Date.now()) {
if (!Array.isArray(items) || items.length > this.maximumSessions) {
return "overflow";
}
const next = new Map();
for (const session of items) {
if (!isPublicSession(session) || next.has(session.listingId)) {
return "invalid";
}
next.set(session.listingId, { session, updatedAt });
}
this.entries = next;
this.cursor = validCursor(cursor) ? cursor : "";
this.hasMore = Boolean(hasMore);
return "applied";
}
apply(event, updatedAt = Date.now()) {
if (!event || event.contractVersion !== CONTRACT_VERSION || !validCursor(event.cursor)) {
return "invalid";
}
if (event.kind === "sessionUpsert" && isPublicSession(event.session) && event.listingId == null) {
if (!this.entries.has(event.session.listingId)
&& this.entries.size >= this.maximumSessions) {
return "overflow";
}
this.entries.set(event.session.listingId, { session: event.session, updatedAt });
this.cursor = event.cursor;
return "applied";
}
if (event.kind === "sessionRemove"
&& typeof event.listingId === "string"
&& event.listingId.length > 0
&& event.session == null) {
this.entries.delete(event.listingId);
this.cursor = event.cursor;
return this.hasMore ? "refresh" : "applied";
}
if (event.kind === "keepalive" && event.session == null && event.listingId == null) {
this.cursor = event.cursor;
return "keepalive";
}
if (event.kind === "reset" && event.session == null && event.listingId == null) {
this.cursor = event.cursor;
return "reset";
}
return "invalid";
}
sessions() {
return [...this.entries.values()]
.sort((left, right) => left.session.listingId.localeCompare(right.session.listingId));
}
}
export function safeText(value, maximumLength = 160) {
const text = typeof value === "string" ? value : String(value ?? "");
const visible = text.replace(/[\u0000-\u001f\u007f]/gu, "");
return visible.length <= maximumLength
? visible
: `${visible.slice(0, Math.max(0, maximumLength - 1))}`;
}
export function buildBrowseUrl(filter) {
validateFilter(filter);
const query = new URLSearchParams({
contractVersion: String(CONTRACT_VERSION),
gameId: filter.gameId,
environmentId: filter.environmentId,
protocolVersion: String(filter.protocolVersion),
regionId: filter.regionId,
pageSize: String(Math.min(BROWSER_PAGE_LIMIT, filter.pageSize ?? BROWSER_PAGE_LIMIT)),
excludeFull: String(Boolean(filter.excludeFull)),
});
return `/v1/sessions?${query}`;
}
export function buildStreamUrl(filter, cursor) {
validateFilter(filter);
if (!validCursor(cursor)) {
throw new TypeError("A bounded stream cursor is required");
}
const query = new URLSearchParams({
contractVersion: String(CONTRACT_VERSION),
gameId: filter.gameId,
environmentId: filter.environmentId,
protocolVersion: String(filter.protocolVersion),
regionId: filter.regionId,
excludeFull: String(Boolean(filter.excludeFull)),
streamCursor: cursor,
});
return `/v1/sessions/stream?${query}`;
}
export function chooseSnapshotTransport(hasMore, pollingOnly) {
if (pollingOnly) {
return "pollingOnly";
}
return hasMore ? "boundedPolling" : "stream";
}
function validCursor(value) {
return typeof value === "string"
&& value.length > 0
&& value.length <= 1024
&& /^[\x21-\x7e]+$/u.test(value);
}
function isPublicSession(session) {
return session != null
&& session.contractVersion === CONTRACT_VERSION
&& typeof session.listingId === "string"
&& session.listingId.length > 0
&& typeof session.gameId === "string"
&& typeof session.environmentId === "string"
&& typeof session.regionId === "string"
&& Number.isInteger(session.protocolVersion)
&& session.protocolVersion > 0
&& typeof session.buildVersion === "string"
&& typeof session.displayName === "string"
&& session.visibility === "public"
&& session.capacity != null
&& Number.isInteger(session.capacity.currentPlayers)
&& Number.isInteger(session.capacity.maximumPlayers)
&& session.capacity.currentPlayers >= 0
&& session.capacity.maximumPlayers >= 1
&& session.capacity.currentPlayers <= session.capacity.maximumPlayers
&& session.metadata != null
&& typeof session.metadata === "object"
&& !Array.isArray(session.metadata);
}
function validateFilter(filter) {
if (!filter
|| typeof filter.gameId !== "string"
|| typeof filter.environmentId !== "string"
|| typeof filter.regionId !== "string"
|| !Number.isInteger(filter.protocolVersion)
|| filter.protocolVersion <= 0) {
throw new TypeError("The selected diagnostic filter is invalid");
}
}
function startDashboard() {
const elements = {
form: document.querySelector("#filters"),
game: document.querySelector("#game-filter"),
environment: document.querySelector("#environment-filter"),
protocol: document.querySelector("#protocol-filter"),
region: document.querySelector("#region-filter"),
capacity: document.querySelector("#capacity-filter"),
error: document.querySelector("#filter-error"),
reconnect: document.querySelector("#reconnect-button"),
reset: document.querySelector("#reset-button"),
poll: document.querySelector("#poll-button"),
streamStatus: document.querySelector("#stream-status"),
transportState: document.querySelector("#transport-state"),
projectionState: document.querySelector("#projection-state"),
lastUpdate: document.querySelector("#last-update"),
sessionCount: document.querySelector("#session-count"),
results: document.querySelector(".results-panel"),
resultsSummary: document.querySelector("#results-summary"),
list: document.querySelector("#session-list"),
empty: document.querySelector("#empty-state"),
};
const state = {
configuration: null,
projection: null,
activeFilter: null,
source: null,
pollingTimer: null,
requestController: null,
streamFailures: 0,
pollingOnly: false,
renderPending: false,
lastSuccess: 0,
};
function setStatus(kind, message, transport = message) {
document.body.dataset.streamState = kind;
elements.streamStatus.textContent = message;
elements.transportState.textContent = transport;
}
function setFormError(message = "") {
elements.error.textContent = message;
elements.error.hidden = message.length === 0;
}
function populate(select, values, previous) {
select.replaceChildren();
for (const value of values) {
const option = document.createElement("option");
option.value = String(value);
option.textContent = safeText(value, 80);
select.append(option);
}
if (values.some((value) => String(value) === previous)) {
select.value = previous;
}
}
function selectedScope() {
return state.configuration?.scopes.find((scope) =>
scope.gameId === elements.game.value
&& scope.environmentId === elements.environment.value) ?? null;
}
function updateEnvironmentOptions() {
const prior = elements.environment.value;
const environments = state.configuration.scopes
.filter((scope) => scope.gameId === elements.game.value)
.map((scope) => scope.environmentId);
populate(elements.environment, environments, prior);
updateScopeOptions();
}
function updateScopeOptions() {
const scope = selectedScope();
populate(elements.protocol, scope?.protocolVersions ?? [], elements.protocol.value);
populate(elements.region, scope?.regions ?? [], elements.region.value);
}
function currentFilter() {
const scope = selectedScope();
const protocolVersion = Number(elements.protocol.value);
if (!scope
|| !scope.protocolVersions.includes(protocolVersion)
|| !scope.regions.includes(elements.region.value)) {
throw new TypeError("Choose one of the configured game, environment, protocol, and region combinations.");
}
return {
gameId: scope.gameId,
environmentId: scope.environmentId,
protocolVersion,
regionId: elements.region.value,
excludeFull: elements.capacity.value === "open",
pageSize: Math.min(BROWSER_PAGE_LIMIT, state.configuration.maximumRenderedSessions),
};
}
function stopLiveWork() {
if (state.source) {
state.source.close();
state.source = null;
}
if (state.pollingTimer) {
clearTimeout(state.pollingTimer);
state.pollingTimer = null;
}
if (state.requestController) {
state.requestController.abort();
state.requestController = null;
}
}
function markSuccess(message) {
state.lastSuccess = Date.now();
elements.lastUpdate.dateTime = new Date(state.lastSuccess).toISOString();
elements.lastUpdate.textContent = "Just now";
elements.resultsSummary.textContent = message;
}
function scheduleRender() {
if (state.renderPending) {
return;
}
state.renderPending = true;
requestAnimationFrame(() => {
state.renderPending = false;
renderProjection();
});
}
function appendDetail(list, term, description) {
const dt = document.createElement("dt");
dt.textContent = term;
const dd = document.createElement("dd");
dd.textContent = safeText(description, 160);
list.append(dt, dd);
}
function sessionCard(entry) {
const session = entry.session;
const article = document.createElement("article");
article.className = "session-card";
article.setAttribute("role", "listitem");
article.dataset.updatedAt = String(entry.updatedAt);
const heading = document.createElement("div");
heading.className = "card-heading";
const title = document.createElement("h3");
title.textContent = safeText(session.displayName, 120);
const region = document.createElement("span");
region.className = "region-badge";
region.textContent = safeText(session.regionId, 48);
heading.append(title, region);
const details = document.createElement("dl");
details.className = "session-detail";
appendDetail(details, "Build", session.buildVersion);
appendDetail(details, "Protocol", session.protocolVersion);
appendDetail(details, "Visibility", "Public");
appendDetail(details, "Presence", "Recently verified");
const capacity = document.createElement("div");
capacity.className = "capacity-row";
const capacityCopy = document.createElement("div");
capacityCopy.className = "capacity-copy";
const capacityLabel = document.createElement("span");
capacityLabel.textContent = "Advisory capacity";
const capacityValue = document.createElement("span");
capacityValue.textContent = `${session.capacity.currentPlayers} / ${session.capacity.maximumPlayers}`;
capacityCopy.append(capacityLabel, capacityValue);
const meter = document.createElement("meter");
meter.min = 0;
meter.max = session.capacity.maximumPlayers;
meter.value = session.capacity.currentPlayers;
meter.setAttribute("aria-label", `Advisory capacity ${capacityValue.textContent}`);
capacity.append(capacityCopy, meter);
const metadata = document.createElement("ul");
metadata.className = "metadata-list";
for (const [key, value] of Object.entries(session.metadata).slice(0, 16)) {
const item = document.createElement("li");
item.textContent = `${safeText(key, 48)}: ${safeText(value, 96)}`;
metadata.append(item);
}
if (metadata.childElementCount === 0) {
const item = document.createElement("li");
item.textContent = "No public metadata";
metadata.append(item);
}
const updated = document.createElement("p");
updated.className = "updated-age";
updated.textContent = "Updated just now";
article.append(heading, details, capacity, metadata, updated);
return article;
}
function renderProjection() {
const entries = state.projection?.sessions() ?? [];
const fragment = document.createDocumentFragment();
for (const entry of entries) {
fragment.append(sessionCard(entry));
}
elements.list.replaceChildren(fragment);
elements.sessionCount.textContent = String(entries.length);
elements.empty.hidden = entries.length !== 0;
elements.projectionState.textContent = state.projection?.hasMore
? "Bounded snapshot; polling"
: "Snapshot plus ordered deltas";
elements.results.setAttribute("aria-busy", "false");
}
function updateAges() {
const now = Date.now();
if (state.lastSuccess > 0) {
const age = Math.max(0, Math.floor((now - state.lastSuccess) / 1000));
elements.lastUpdate.textContent = age < 5 ? "Just now" : `${age} seconds ago`;
}
for (const card of elements.list.querySelectorAll(".session-card")) {
const age = Math.max(0, Math.floor((now - Number(card.dataset.updatedAt)) / 1000));
const copy = card.querySelector(".updated-age");
copy.textContent = age < 5 ? "Updated just now" : `Updated ${age} seconds ago`;
}
}
async function readJson(url) {
const controller = new AbortController();
state.requestController = controller;
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, {
cache: "no-store",
credentials: "same-origin",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`The service returned HTTP ${response.status}.`);
}
return await response.json();
} finally {
clearTimeout(timeout);
if (state.requestController === controller) {
state.requestController = null;
}
}
}
function schedulePolling() {
if (state.pollingTimer) {
clearTimeout(state.pollingTimer);
}
state.pollingTimer = setTimeout(
() => fetchSnapshot("poll").catch(showServiceError),
state.configuration.pollIntervalSeconds * 1000,
);
}
function usePolling(message = "Polling fallback active") {
if (state.source) {
state.source.close();
state.source = null;
}
setStatus("polling", message, "Polling fallback");
schedulePolling();
}
function showServiceError(error) {
const message = error?.name === "AbortError"
? "The service did not answer within the request deadline."
: safeText(error?.message || "The service is unavailable.", 200);
setStatus("error", "Service unavailable", "Unavailable");
elements.resultsSummary.textContent = `${message} Retrying with bounded polling.`;
elements.results.setAttribute("aria-busy", "false");
usePolling("Service unavailable; polling retry scheduled");
}
async function fetchSnapshot(reason = "manual") {
if (!state.activeFilter) {
return;
}
if (state.requestController) {
state.requestController.abort();
}
elements.results.setAttribute("aria-busy", "true");
if (reason !== "poll") {
setStatus("reconnecting", "Loading a fresh snapshot", "Snapshot request");
}
const response = await readJson(buildBrowseUrl(state.activeFilter));
if (response.contractVersion !== CONTRACT_VERSION
|| !Array.isArray(response.items)
|| !validCursor(response.streamCursor)) {
throw new Error("The service returned an invalid browser snapshot.");
}
const outcome = state.projection.replace(
response.items,
response.streamCursor,
Boolean(response.nextCursor),
);
if (outcome !== "applied") {
throw new Error("The bounded browser snapshot could not be applied safely.");
}
scheduleRender();
markSuccess(`${response.items.length} public session${response.items.length === 1 ? "" : "s"} in the fresh snapshot.`);
const transport = chooseSnapshotTransport(Boolean(response.nextCursor), state.pollingOnly);
if (transport !== "stream") {
if (transport === "pollingOnly") {
usePolling("Polling only selected");
return;
}
usePolling("More sessions exist than this bounded view; polling keeps it authoritative");
return;
}
connectStream(response.streamCursor);
}
function handleStreamEvent(serialized, expectedKind) {
let event;
try {
event = JSON.parse(serialized);
} catch {
usePolling("Invalid stream data; polling fallback active");
return;
}
if (event.kind !== expectedKind) {
usePolling("Unexpected stream event; polling fallback active");
return;
}
const outcome = state.projection.apply(event);
if (outcome === "invalid" || outcome === "overflow" || outcome === "refresh") {
fetchSnapshot("stream-recovery").catch(showServiceError);
return;
}
if (outcome === "reset") {
setStatus("reset", "Cursor reset; refreshing snapshot", "Cursor reset");
fetchSnapshot("reset").catch(showServiceError);
return;
}
if (outcome === "keepalive") {
markSuccess("Live connection healthy; no listing changes.");
return;
}
state.streamFailures = 0;
scheduleRender();
markSuccess(expectedKind === "sessionUpsert"
? "Applied a live session add or update."
: "Applied a live session removal.");
}
function connectStream(cursor, corrupt = false) {
if (state.pollingTimer) {
clearTimeout(state.pollingTimer);
state.pollingTimer = null;
}
if (state.source) {
state.source.close();
}
const selectedCursor = corrupt
? `${cursor.slice(0, -1)}${cursor.endsWith("a") ? "b" : "a"}`
: cursor;
setStatus(corrupt ? "reset" : "reconnecting",
corrupt ? "Testing cursor reset" : "Connecting live updates",
corrupt ? "Cursor reset test" : "SSE reconnecting");
const source = new EventSource(buildStreamUrl(state.activeFilter, selectedCursor));
state.source = source;
source.addEventListener("open", () => {
if (state.source !== source) {
return;
}
state.streamFailures = 0;
setStatus("connected", "Live updates connected", "SSE live");
});
for (const [name, kind] of [
["session_upsert", "sessionUpsert"],
["session_remove", "sessionRemove"],
["reset", "reset"],
["keepalive", "keepalive"],
]) {
source.addEventListener(name, (message) => {
if (state.source === source) {
handleStreamEvent(message.data, kind);
}
});
}
source.addEventListener("error", () => {
if (state.source !== source) {
return;
}
state.streamFailures += 1;
if (state.streamFailures >= STREAM_FAILURE_LIMIT || source.readyState === EventSource.CLOSED) {
usePolling("Live stream unavailable; polling fallback active");
} else {
setStatus("reconnecting", "Live stream interrupted; reconnecting", "SSE reconnecting");
}
});
}
async function loadConfiguration() {
const configuration = await readJson("/diagnostics/config.json");
if (configuration.contractVersion !== CONTRACT_VERSION
|| !Array.isArray(configuration.scopes)
|| configuration.scopes.length < 1
|| !Number.isInteger(configuration.pollIntervalSeconds)
|| !Number.isInteger(configuration.maximumRenderedSessions)) {
throw new Error("The diagnostic configuration is invalid.");
}
state.configuration = configuration;
state.projection = new SessionProjection(configuration.maximumRenderedSessions);
populate(elements.game, [...new Set(configuration.scopes.map((scope) => scope.gameId))], "");
updateEnvironmentOptions();
state.activeFilter = currentFilter();
await fetchSnapshot("startup");
}
elements.game.addEventListener("change", updateEnvironmentOptions);
elements.environment.addEventListener("change", updateScopeOptions);
elements.form.addEventListener("submit", (event) => {
event.preventDefault();
try {
setFormError();
stopLiveWork();
state.pollingOnly = false;
state.activeFilter = currentFilter();
state.projection = new SessionProjection(state.configuration.maximumRenderedSessions);
fetchSnapshot("filter").catch(showServiceError);
} catch (error) {
setFormError(safeText(error.message, 200));
}
});
elements.reconnect.addEventListener("click", () => {
if (state.projection?.cursor) {
state.pollingOnly = false;
connectStream(state.projection.cursor);
}
});
elements.reset.addEventListener("click", () => {
if (state.projection?.cursor) {
state.pollingOnly = false;
connectStream(state.projection.cursor, true);
}
});
elements.poll.addEventListener("click", () => {
state.pollingOnly = true;
usePolling("Polling only selected deliberately");
fetchSnapshot("poll").catch(showServiceError);
});
window.addEventListener("pagehide", stopLiveWork, { once: true });
setInterval(updateAges, 5000);
loadConfiguration().catch(showServiceError);
}
if (typeof document !== "undefined") {
startDashboard();
}
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Session diagnostics — Rendezvous</title>
<link rel="stylesheet" href="/diagnostics/styles.css">
<script type="module" src="/diagnostics/app.mjs"></script>
</head>
<body>
<a class="skip-link" href="#main-content">Skip to session results</a>
<header class="site-header">
<div>
<p class="eyebrow">Final Factory infrastructure</p>
<h1>Rendezvous session diagnostics</h1>
<p class="lede">A read-only view of public session discovery. Capacity is advisory; joining always revalidates current state.</p>
</div>
<div class="connection-state" aria-live="polite" aria-atomic="true">
<span class="status-dot" aria-hidden="true"></span>
<span id="stream-status">Loading configuration</span>
</div>
</header>
<main id="main-content" tabindex="-1">
<section class="filter-panel" aria-labelledby="filter-heading">
<div class="section-heading">
<div>
<p class="eyebrow">Public browser scope</p>
<h2 id="filter-heading">Choose a configured session view</h2>
</div>
<p class="boundary-note">This page has no join, publish, or operator authority.</p>
</div>
<form id="filters" novalidate>
<div class="filter-grid">
<label>
<span>Game</span>
<select id="game-filter" name="game" required></select>
</label>
<label>
<span>Environment</span>
<select id="environment-filter" name="environment" required></select>
</label>
<label>
<span>Protocol</span>
<select id="protocol-filter" name="protocol" required></select>
</label>
<label>
<span>Region</span>
<select id="region-filter" name="region" required></select>
</label>
<label>
<span>Availability</span>
<select id="capacity-filter" name="capacity">
<option value="open">Open slots only</option>
<option value="all">Include full sessions</option>
</select>
</label>
<label>
<span>Visibility</span>
<select id="visibility-filter" name="visibility" disabled>
<option value="public">Public only</option>
</select>
</label>
</div>
<p id="filter-error" class="form-error" role="alert" hidden></p>
<div class="button-row">
<button class="button primary" type="submit">Apply filters</button>
<button class="button" id="reconnect-button" type="button">Reconnect now</button>
<button class="button" id="reset-button" type="button">Test reset recovery</button>
<button class="button" id="poll-button" type="button">Use polling only</button>
</div>
</form>
</section>
<section class="summary-panel" aria-labelledby="summary-heading">
<h2 id="summary-heading" class="visually-hidden">Current diagnostic state</h2>
<dl class="summary-grid">
<div>
<dt>Sessions shown</dt>
<dd id="session-count">0</dd>
</div>
<div>
<dt>Transport</dt>
<dd id="transport-state">Starting</dd>
</div>
<div>
<dt>Last successful update</dt>
<dd><time id="last-update">Not yet</time></dd>
</div>
<div>
<dt>Projection</dt>
<dd id="projection-state">Waiting for snapshot</dd>
</div>
</dl>
</section>
<section class="results-panel" aria-labelledby="results-heading" aria-busy="true">
<div class="section-heading results-heading">
<div>
<p class="eyebrow">Bounded public projection</p>
<h2 id="results-heading">Available sessions</h2>
</div>
<p id="results-summary" role="status" aria-live="polite">Loading a fresh snapshot…</p>
</div>
<div id="session-list" class="session-grid" role="list"></div>
<div id="empty-state" class="empty-state" hidden>
<h3>No compatible public sessions</h3>
<p>The service answered successfully, but this configured filter currently has no listings.</p>
</div>
</section>
</main>
<footer>
<p>Read-only diagnostics · no credentials stored · no gameplay or administration</p>
</footer>
<noscript>This diagnostic requires JavaScript to consume the public snapshot and event stream.</noscript>
</body>
</html>
@@ -0,0 +1,505 @@
:root {
color-scheme: dark;
--bg: #071019;
--surface: #101c28;
--surface-raised: #172737;
--border: #375066;
--text: #f2f7fb;
--muted: #b6c8d6;
--accent: #55d7b5;
--accent-strong: #7ce9cc;
--accent-ink: #04231c;
--warning: #ffd479;
--danger: #ff9f9f;
--focus: #ffd479;
--radius: 0.75rem;
--space-1: 0.5rem;
--space-2: 0.75rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2rem;
--space-6: 3rem;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 100%;
line-height: 1.55;
}
* {
box-sizing: border-box;
}
html {
min-width: 20rem;
background: var(--bg);
}
body {
min-height: 100vh;
margin: 0;
color: var(--text);
background:
radial-gradient(circle at 10% -10%, rgb(36 103 104 / 35%), transparent 32rem),
linear-gradient(180deg, #09141f 0%, var(--bg) 50%);
}
button,
select {
font: inherit;
}
button,
select,
a {
-webkit-tap-highlight-color: transparent;
}
:focus-visible {
outline: 0.2rem solid var(--focus);
outline-offset: 0.2rem;
}
.skip-link {
position: fixed;
z-index: 10;
top: var(--space-2);
left: var(--space-2);
padding: var(--space-2) var(--space-3);
color: #071019;
background: var(--focus);
border-radius: 0.4rem;
font-weight: 800;
transform: translateY(-200%);
}
.skip-link:focus {
transform: translateY(0);
}
.site-header,
main,
footer {
width: min(80rem, calc(100% - 2rem));
margin-inline: auto;
}
.site-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: var(--space-5);
padding-block: var(--space-6) var(--space-5);
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
max-width: 18ch;
margin-bottom: var(--space-2);
font-size: clamp(2rem, 6vw, 4.25rem);
line-height: 1.02;
letter-spacing: -0.045em;
}
h2 {
margin-bottom: var(--space-1);
font-size: clamp(1.35rem, 3vw, 2rem);
line-height: 1.2;
}
h3 {
margin-bottom: var(--space-1);
font-size: 1.1rem;
}
.eyebrow {
margin-bottom: var(--space-1);
color: var(--accent-strong);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.lede {
max-width: 65ch;
margin-bottom: 0;
color: var(--muted);
font-size: 1.05rem;
}
.connection-state {
display: inline-flex;
min-height: 2.75rem;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: 999px;
background: rgb(16 28 40 / 88%);
color: var(--muted);
font-weight: 700;
white-space: nowrap;
}
.status-dot {
width: 0.7rem;
height: 0.7rem;
border: 2px solid currentColor;
border-radius: 50%;
background: currentColor;
}
body[data-stream-state="connected"] .connection-state {
color: var(--accent-strong);
}
body[data-stream-state="reconnecting"] .connection-state,
body[data-stream-state="polling"] .connection-state,
body[data-stream-state="reset"] .connection-state {
color: var(--warning);
}
body[data-stream-state="error"] .connection-state {
color: var(--danger);
}
main {
display: grid;
gap: var(--space-4);
}
.filter-panel,
.summary-panel,
.results-panel {
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgb(16 28 40 / 94%);
box-shadow: 0 1rem 3rem rgb(0 0 0 / 18%);
}
.filter-panel,
.results-panel {
padding: clamp(1rem, 4vw, 2rem);
}
.section-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.boundary-note,
#results-summary {
max-width: 34rem;
margin-bottom: 0;
color: var(--muted);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
label {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.9rem;
font-weight: 750;
}
select {
width: 100%;
min-height: 2.75rem;
padding: 0.6rem 2.4rem 0.6rem 0.75rem;
border: 1px solid #587189;
border-radius: 0.45rem;
color: var(--text);
background: #0b1722;
}
select:disabled {
color: #9fb0bd;
border-style: dashed;
opacity: 1;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-4);
}
.button {
min-height: 2.75rem;
padding: 0.65rem 1rem;
border: 1px solid #6a8298;
border-radius: 0.45rem;
color: var(--text);
background: var(--surface-raised);
cursor: pointer;
font-weight: 800;
}
.button:hover {
border-color: var(--accent-strong);
background: #21384a;
}
.button:active {
transform: translateY(1px);
}
.button.primary {
color: var(--accent-ink);
border-color: var(--accent);
background: var(--accent);
}
.button.primary:hover {
background: var(--accent-strong);
}
.form-error {
margin: var(--space-3) 0 0;
color: var(--danger);
font-weight: 750;
}
.summary-panel {
padding: 0;
overflow: hidden;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
}
.summary-grid > div {
min-width: 0;
padding: var(--space-3) var(--space-4);
border-right: 1px solid var(--border);
}
.summary-grid > div:last-child {
border-right: 0;
}
.summary-grid dt {
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
}
.summary-grid dd {
margin: 0.25rem 0 0;
overflow-wrap: anywhere;
font-size: 1.05rem;
font-weight: 800;
}
.results-heading {
align-items: end;
}
.session-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
gap: var(--space-3);
}
.session-card {
min-width: 0;
padding: var(--space-3);
border: 1px solid #496177;
border-radius: 0.6rem;
background: #0b1722;
}
.card-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--space-2);
}
.card-heading h3 {
overflow-wrap: anywhere;
}
.region-badge {
flex: 0 0 auto;
padding: 0.15rem 0.5rem;
border: 1px solid #597187;
border-radius: 999px;
color: var(--muted);
font-size: 0.75rem;
font-weight: 750;
}
.session-detail {
display: grid;
grid-template-columns: minmax(5rem, auto) 1fr;
gap: 0.25rem var(--space-2);
margin: var(--space-3) 0 0;
font-size: 0.9rem;
}
.session-detail dt {
color: var(--muted);
}
.session-detail dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.capacity-row {
margin-top: var(--space-3);
}
.capacity-copy {
display: flex;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.85rem;
}
meter {
width: 100%;
height: 0.65rem;
accent-color: var(--accent);
}
.metadata-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: var(--space-3) 0 0;
padding: 0;
list-style: none;
}
.metadata-list li {
max-width: 100%;
padding: 0.2rem 0.45rem;
overflow-wrap: anywhere;
border-radius: 0.3rem;
color: #d6e4ed;
background: #1b2b39;
font-size: 0.78rem;
}
.updated-age {
margin: var(--space-3) 0 0;
color: var(--muted);
font-size: 0.78rem;
}
.empty-state {
padding: var(--space-6) var(--space-3);
text-align: center;
border: 1px dashed #587189;
border-radius: 0.6rem;
color: var(--muted);
}
.empty-state h3 {
color: var(--text);
}
footer {
padding-block: var(--space-5);
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
[hidden] {
display: none !important;
}
@media (max-width: 56rem) {
.site-header,
.section-heading {
align-items: start;
flex-direction: column;
}
.filter-grid,
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-grid > div:nth-child(2) {
border-right: 0;
}
.summary-grid > div:nth-child(-n + 2) {
border-bottom: 1px solid var(--border);
}
}
@media (max-width: 36rem) {
.site-header,
main,
footer {
width: min(100% - 1rem, 80rem);
}
.site-header {
padding-block: var(--space-5) var(--space-4);
}
.filter-grid,
.summary-grid {
grid-template-columns: 1fr;
}
.summary-grid > div {
border-right: 0;
border-bottom: 1px solid var(--border);
}
.summary-grid > div:last-child {
border-bottom: 0;
}
.button {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,111 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Diagnostics;
internal static class DiagnosticDashboardEndpoints
{
private const string ContentSecurityPolicy =
"default-src 'none'; base-uri 'none'; connect-src 'self'; "
+ "font-src 'self'; form-action 'none'; frame-ancestors 'none'; "
+ "img-src 'self'; manifest-src 'none'; object-src 'none'; "
+ "script-src 'self'; style-src 'self'";
private static readonly byte[] Index = ReadAsset("index.html");
private static readonly byte[] Script = ReadAsset("app.mjs");
private static readonly byte[] Styles = ReadAsset("styles.css");
public static IEndpointRouteBuilder MapDiagnosticDashboardEndpoints(
this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder dashboard = endpoints.MapGroup("/diagnostics")
.ExcludeFromDescription();
dashboard.MapGet("", ServeIndex);
dashboard.MapGet("/app.mjs", ServeScript);
dashboard.MapGet("/styles.css", ServeStyles);
dashboard.MapGet("/config.json", ServeConfiguration);
return endpoints;
}
private static IResult ServeIndex(
HttpContext context,
[FromServices] IOptions<DiagnosticDashboardOptions> configured) =>
ServeAsset(context, configured.Value, Index, "text/html; charset=utf-8");
private static IResult ServeScript(
HttpContext context,
[FromServices] IOptions<DiagnosticDashboardOptions> configured) =>
ServeAsset(context, configured.Value, Script, "text/javascript; charset=utf-8");
private static IResult ServeStyles(
HttpContext context,
[FromServices] IOptions<DiagnosticDashboardOptions> configured) =>
ServeAsset(context, configured.Value, Styles, "text/css; charset=utf-8");
private static IResult ServeConfiguration(
HttpContext context,
[FromServices] IOptions<DiagnosticDashboardOptions> configured)
{
DiagnosticDashboardOptions options = configured.Value;
if (!options.Enabled)
{
return Results.NotFound();
}
ApplySecurityHeaders(context.Response);
return Results.Json(new
{
contractVersion = ContractLimits.ContractVersion,
pollIntervalSeconds = options.PollIntervalSeconds,
maximumRenderedSessions = options.MaximumRenderedSessions,
scopes = options.Scopes.Select(static scope => new
{
gameId = scope.GameId,
environmentId = scope.EnvironmentId,
protocolVersions = scope.ProtocolVersions,
regions = scope.Regions,
visibility = "public",
}),
});
}
private static IResult ServeAsset(
HttpContext context,
DiagnosticDashboardOptions options,
byte[] content,
string contentType)
{
if (!options.Enabled)
{
return Results.NotFound();
}
ApplySecurityHeaders(context.Response);
return Results.Bytes(content, contentType);
}
private static void ApplySecurityHeaders(HttpResponse response)
{
response.Headers.CacheControl = "no-store";
response.Headers["Content-Security-Policy"] = ContentSecurityPolicy;
response.Headers["X-Content-Type-Options"] = "nosniff";
response.Headers["X-Frame-Options"] = "DENY";
response.Headers["Cross-Origin-Opener-Policy"] = "same-origin";
response.Headers["Cross-Origin-Resource-Policy"] = "same-origin";
response.Headers["Permissions-Policy"] =
"camera=(), geolocation=(), microphone=(), payment=(), usb=()";
response.Headers["Referrer-Policy"] = "no-referrer";
}
private static byte[] ReadAsset(string fileName)
{
Assembly assembly = typeof(DiagnosticDashboardEndpoints).Assembly;
string resourceName = $"FinalFactory.Rendezvous.Server.Diagnostics.Assets.{fileName}";
using Stream source = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Missing embedded dashboard asset {fileName}.");
using MemoryStream destination = new();
source.CopyTo(destination);
return destination.ToArray();
}
}
@@ -0,0 +1,81 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Diagnostics;
internal sealed record DiagnosticDashboardOptions
{
public const string SectionName = "Rendezvous:Diagnostics";
public bool Enabled { get; init; }
public int PollIntervalSeconds { get; init; } = 10;
public int MaximumRenderedSessions { get; init; } = 100;
public DiagnosticDashboardScope[] Scopes { get; init; } = [];
public IReadOnlyList<string> Validate()
{
List<string> errors = [];
if (PollIntervalSeconds is < 5 or > 60)
{
errors.Add($"{SectionName}:PollIntervalSeconds must be between 5 and 60.");
}
if (MaximumRenderedSessions is < 10 or > 500)
{
errors.Add($"{SectionName}:MaximumRenderedSessions must be between 10 and 500.");
}
if (!Enabled)
{
return errors;
}
if (Scopes is null || Scopes.Length is < 1 or > 32)
{
errors.Add($"{SectionName}:Scopes must contain between 1 and 32 allow-listed scopes when enabled.");
return errors;
}
HashSet<string> identities = new(StringComparer.Ordinal);
foreach (DiagnosticDashboardScope? scope in Scopes)
{
if (scope is null)
{
errors.Add($"{SectionName}:Scopes cannot contain null entries.");
continue;
}
string gameId = scope.GameId ?? string.Empty;
string environmentId = scope.EnvironmentId ?? string.Empty;
uint[] protocolVersions = scope.ProtocolVersions ?? [];
string[] regions = scope.Regions ?? [];
if (!GameId.TryParse(gameId, out _)
|| !EnvironmentId.TryParse(environmentId, out _))
{
errors.Add($"{SectionName}:Scopes contains an invalid game or environment identifier.");
}
if (protocolVersions.Length is < 1 or > 16
|| protocolVersions.Any(static version => version == 0)
|| protocolVersions.Distinct().Count() != protocolVersions.Length)
{
errors.Add($"{SectionName}:Scopes protocol versions must contain 1-16 unique positive values.");
}
if (regions.Length is < 1 or > 16
|| regions.Any(static region => !RegionId.TryParse(region ?? string.Empty, out _))
|| regions.Distinct(StringComparer.Ordinal).Count() != regions.Length)
{
errors.Add($"{SectionName}:Scopes regions must contain 1-16 unique valid identifiers.");
}
string identity = $"{gameId}\n{environmentId}";
if (!identities.Add(identity))
{
errors.Add($"{SectionName}:Scopes contains a duplicate game/environment pair.");
}
}
return errors;
}
}
internal sealed record DiagnosticDashboardScope
{
public string GameId { get; init; } = string.Empty;
public string EnvironmentId { get; init; } = string.Empty;
public uint[] ProtocolVersions { get; init; } = [];
public string[] Regions { get; init; } = [];
}
@@ -3,6 +3,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<AssemblyName>FinalFactory.Rendezvous.Server</AssemblyName> <AssemblyName>FinalFactory.Rendezvous.Server</AssemblyName>
<RootNamespace>FinalFactory.Rendezvous.Server</RootNamespace> <RootNamespace>FinalFactory.Rendezvous.Server</RootNamespace>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments> <OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../docs/api</OpenApiDocumentsDirectory> <OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../docs/api</OpenApiDocumentsDirectory>
@@ -14,4 +15,91 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" PrivateAssets="all" /> <PackageReference Include="Microsoft.Extensions.ApiDescription.Server" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>RendezvousMinimumClientVersion</_Parameter1>
<_Parameter2>$(MinimumClientVersion)</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>RendezvousMaximumClientMajorVersion</_Parameter1>
<_Parameter2>$(MaximumClientMajorVersion)</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>RendezvousUdpContractVersion</_Parameter1>
<_Parameter2>$(UdpContractVersion)</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>RendezvousConnectionTicketFormatVersion</_Parameter1>
<_Parameter2>$(ConnectionTicketFormatVersion)</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>RendezvousLiteNetLibMajorVersion</_Parameter1>
<_Parameter2>$(LiteNetLibMajorVersion)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<!-- Roslyn and source generators consume syntax trees in item order. Keep
this ordinal manifest explicit so clean builds are byte reproducible. -->
<Compile Include="Abuse/AbuseProtectionOptions.cs" />
<Compile Include="Abuse/AbuseProtectionService.cs" />
<Compile Include="Abuse/HttpAbuseProtectionMiddleware.cs" />
<Compile Include="Abuse/TrustedProxyForwarding.cs" />
<Compile Include="Browser/EphemeralCursorProtector.cs" />
<Compile Include="Browser/SessionBrowserCursorCodec.cs" />
<Compile Include="Browser/SessionBrowserService.cs" />
<Compile Include="Browser/SessionChangeJournal.cs" />
<Compile Include="Browser/SessionStreamCursorCodec.cs" />
<Compile Include="Browser/SessionStreamService.cs" />
<Compile Include="ConnectionOutcomes/ConnectionOutcomeService.cs" />
<Compile Include="Deployment/DeploymentOptions.cs" />
<Compile Include="Deployment/GracefulDrainService.cs" />
<Compile Include="Diagnostics/DiagnosticDashboardEndpoints.cs" />
<Compile Include="Diagnostics/DiagnosticDashboardOptions.cs" />
<Compile Include="Http/ContractEndpoints.cs" />
<Compile Include="Http/RendezvousExceptionHandler.cs" />
<Compile Include="JoinAttempts/JoinAttemptCursorCodec.cs" />
<Compile Include="JoinAttempts/JoinAttemptService.cs" />
<Compile Include="Observability/AuditOptions.cs" />
<Compile Include="Observability/AuditTrail.cs" />
<Compile Include="Observability/HealthEndpoints.cs" />
<Compile Include="Observability/PrometheusMetricsEndpoint.cs" />
<Compile Include="Observability/RendezvousReadiness.cs" />
<Compile Include="Observability/RendezvousTelemetry.cs" />
<Compile Include="Observability/TelemetryMiddleware.cs" />
<Compile Include="Operations/OperatorEndpoints.cs" />
<Compile Include="Operations/OperatorModels.cs" />
<Compile Include="Operations/OperatorService.cs" />
<Compile Include="Operations/ReleaseCompatibility.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties/AssemblyInfo.cs" />
<Compile Include="Provisioning/GamePolicy.cs" />
<Compile Include="Provisioning/GamePolicyRegistry.cs" />
<Compile Include="Provisioning/PrincipalCredentialService.cs" />
<Compile Include="Provisioning/Principals.cs" />
<Compile Include="Provisioning/ProvisioningOptions.cs" />
<Compile Include="Provisioning/ProvisioningRuntime.cs" />
<Compile Include="Provisioning/PublisherAuthorizationService.cs" />
<Compile Include="Provisioning/SecretProviders.cs" />
<Compile Include="Provisioning/SigningKeyRing.cs" />
<Compile Include="Sessions/EphemeralCapabilityIssuer.cs" />
<Compile Include="Sessions/SessionLeaseService.cs" />
<Compile Include="State/EphemeralStateContracts.cs" />
<Compile Include="State/InMemoryEphemeralRendezvousStore.cs" />
<Compile Include="State/StoreResultMapping.cs" />
<Compile Include="Transport/LiteNetNatRequestCodec.cs" />
<Compile Include="Transport/NatMediationProcessor.cs" />
<Compile Include="Transport/UdpMediatorOptions.cs" />
<Compile Include="Transport/UdpMediatorService.cs" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Diagnostics/Assets/app.mjs">
<LogicalName>FinalFactory.Rendezvous.Server.Diagnostics.Assets.app.mjs</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Diagnostics/Assets/index.html">
<LogicalName>FinalFactory.Rendezvous.Server.Diagnostics.Assets.index.html</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Diagnostics/Assets/styles.css">
<LogicalName>FinalFactory.Rendezvous.Server.Diagnostics.Assets.styles.css</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project> </Project>
@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
@@ -69,6 +70,12 @@ internal static class ContractEndpoints
.Produces<ApiError>(StatusCodes.Status429TooManyRequests) .Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions"); .WithName("BrowseSessions");
sessions.MapGet("/stream", StreamSessions)
.Produces<SessionStreamEvent>(StatusCodes.Status200OK, contentType: "text/event-stream")
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("StreamSessions");
sessions.MapGet("/{listingId}", GetSession) sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>() .Produces<GetSessionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
@@ -349,6 +356,123 @@ internal static class ContractEndpoints
} }
} }
private static async Task<IResult> StreamSessions(
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromQuery] string? regionId,
[FromQuery] bool? excludeFull,
[FromQuery] string? streamCursor,
[FromHeader(Name = "Last-Event-ID")] string? lastEventId,
[FromServices] SessionStreamService streams,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|| regionId is not null && !RegionId.TryParse(regionId, out _))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"StreamSessions",
Tenant(parsedGameId, parsedEnvironmentId),
null,
null,
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
BrowserServiceResult<SessionStreamSubscription> subscribed = streams.Subscribe(new()
{
ContractVersion = contractVersion,
GameId = parsedGameId,
EnvironmentId = parsedEnvironmentId,
ProtocolVersion = protocolVersion,
RegionId = regionId is null ? null : new RegionId(regionId),
ExcludeFull = excludeFull ?? false,
}, string.IsNullOrEmpty(lastEventId) ? streamCursor : lastEventId);
if (!subscribed.Succeeded || subscribed.Value is null)
{
return Error(subscribed.Error);
}
using SessionStreamSubscription subscription = subscribed.Value;
using CancellationTokenSource duration = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
duration.CancelAfter(streams.MaximumConnectionDuration);
HttpResponse response = httpContext.Response;
response.StatusCode = StatusCodes.Status200OK;
response.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache, no-store";
response.Headers["X-Accel-Buffering"] = "no";
await response.StartAsync(duration.Token).ConfigureAwait(false);
try
{
while (!duration.IsCancellationRequested)
{
SessionStreamReadResult read = streams.Read(subscription);
if (read.RequiresReset)
{
await WriteSseAsync(response, streams.ResetEvent(subscription), duration.Token)
.ConfigureAwait(false);
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
break;
}
if (read.Events.Count > 0)
{
foreach (SessionStreamEvent item in read.Events)
{
await WriteSseAsync(response, item, duration.Token).ConfigureAwait(false);
}
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
continue;
}
bool changed = await streams.WaitForChangeAsync(subscription, duration.Token)
.ConfigureAwait(false);
if (!changed)
{
await WriteSseAsync(
response,
streams.KeepaliveEvent(subscription),
duration.Token).ConfigureAwait(false);
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException) when (duration.IsCancellationRequested)
{
}
return Results.Empty;
}
}
private static async Task WriteSseAsync(
HttpResponse response,
SessionStreamEvent item,
CancellationToken cancellationToken)
{
string eventName = item.Kind switch
{
SessionStreamEventKind.SessionUpsert => "session_upsert",
SessionStreamEventKind.SessionRemove => "session_remove",
SessionStreamEventKind.Reset => "reset",
_ => "keepalive",
};
string data = JsonSerializer.Serialize(item, ContractJson.Options);
await response.WriteAsync(
$"id: {item.Cursor}\nevent: {eventName}\ndata: {data}\n\n",
cancellationToken).ConfigureAwait(false);
}
private static IResult GetSession( private static IResult GetSession(
SessionListingId listingId, SessionListingId listingId,
[FromQuery] int contractVersion, [FromQuery] int contractVersion,
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Diagnostics;
namespace FinalFactory.Rendezvous.Server.Http; namespace FinalFactory.Rendezvous.Server.Http;
internal sealed partial class RendezvousExceptionHandler( internal sealed class RendezvousExceptionHandler(
ILogger<RendezvousExceptionHandler> logger) : IExceptionHandler ILogger<RendezvousExceptionHandler> logger) : IExceptionHandler
{ {
public async ValueTask<bool> TryHandleAsync( public async ValueTask<bool> TryHandleAsync(
@@ -51,13 +51,15 @@ internal sealed partial class RendezvousExceptionHandler(
return true; return true;
} }
[LoggerMessage( private static readonly Action<ILogger, string, int, string, Exception?> RequestFailure =
EventId = 200, LoggerMessage.Define<string, int, string>(
Level = LogLevel.Warning, LogLevel.Warning,
Message = "Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}")] new EventId(200, nameof(LogRequestFailure)),
private static partial void LogRequestFailure( "Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}");
private static void LogRequestFailure(
ILogger logger, ILogger logger,
string failureKind, string failureKind,
int statusCode, int statusCode,
string correlationId); string correlationId) => RequestFailure(logger, failureKind, statusCode, correlationId, null);
} }
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Observability; namespace FinalFactory.Rendezvous.Server.Observability;
internal sealed partial class AuditTrail internal sealed class AuditTrail
{ {
private readonly object _gate = new(); private readonly object _gate = new();
private readonly LinkedList<AuditEntry> _entries = []; private readonly LinkedList<AuditEntry> _entries = [];
@@ -57,7 +57,6 @@ internal sealed partial class AuditTrail
_telemetry.RecordAudit(action, result); _telemetry.RecordAudit(action, result);
LogOperatorAction( LogOperatorAction(
_logger, _logger,
entry.Timestamp,
entry.ActorFingerprint, entry.ActorFingerprint,
action, action,
result, result,
@@ -105,19 +104,28 @@ internal sealed partial class AuditTrail
return Convert.ToHexString(digest.AsSpan(0, 12)); return Convert.ToHexString(digest.AsSpan(0, 12));
} }
[LoggerMessage( private static readonly Action<ILogger, string, string, string, string, string, string, Exception?>
EventId = 100, OperatorAction = LoggerMessage.Define<string, string, string, string, string, string>(
Level = LogLevel.Information, LogLevel.Information,
Message = "Operator audit at {Timestamp}: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}")] new EventId(100, nameof(LogOperatorAction)),
private static partial void LogOperatorAction( "Operator audit: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}");
private static void LogOperatorAction(
ILogger logger, ILogger logger,
DateTimeOffset timestamp,
string actorFingerprint, string actorFingerprint,
string action, string action,
string result, string result,
string targetKind, string targetKind,
string targetFingerprint, string targetFingerprint,
string correlationId); string correlationId) => OperatorAction(
logger,
actorFingerprint,
action,
result,
targetKind,
targetFingerprint,
correlationId,
null);
} }
internal sealed record AuditEntry( internal sealed record AuditEntry(
@@ -0,0 +1,148 @@
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Server.Observability;
internal sealed record PrometheusMetricsOptions
{
public const string SectionName = "Rendezvous:Metrics";
public bool Enabled { get; init; }
public string BearerTokenSecretReference { get; init; } = string.Empty;
public IReadOnlyList<string> Validate()
{
if (!Enabled)
{
return [];
}
string reference = BearerTokenSecretReference ?? string.Empty;
bool environmentReference = reference.StartsWith("env:", StringComparison.Ordinal)
&& reference.Length > "env:".Length;
bool absoluteFileReference = reference.StartsWith("file:", StringComparison.Ordinal)
&& Path.IsPathFullyQualified(reference["file:".Length..]);
return reference.Length is < 5 or > 512
|| !(environmentReference || absoluteFileReference)
? [$"{SectionName}:BearerTokenSecretReference must be a bounded env: or absolute file: secret reference when metrics are enabled."]
: [];
}
}
internal sealed class MetricsAccessCredential : IDisposable
{
private byte[]? _token;
private MetricsAccessCredential(byte[] token) => _token = token;
public static bool TryCreate(
PrometheusMetricsOptions options,
ISecretProvider secrets,
out MetricsAccessCredential? credential)
{
credential = null;
if (!options.Enabled
|| !secrets.TryGetSecret(options.BearerTokenSecretReference, out SecretMaterial? material)
|| material is null)
{
return false;
}
using (material)
{
byte[] bytes = material.CopyBytes();
int length = bytes.Length;
while (length > 0 && bytes[length - 1] is (byte)'\r' or (byte)'\n')
{
length--;
}
bool valid = length is >= 32 and <= 128
&& bytes.AsSpan(0, length).IndexOfAnyExceptInRange((byte)0x21, (byte)0x7e) < 0;
if (!valid)
{
CryptographicOperations.ZeroMemory(bytes);
return false;
}
byte[] token = bytes.AsSpan(0, length).ToArray();
CryptographicOperations.ZeroMemory(bytes);
credential = new(token);
return true;
}
}
public bool Authorizes(HttpRequest request)
{
byte[]? expected = _token;
string authorization = request.Headers.Authorization.ToString();
const string prefix = "Bearer ";
if (expected is null
|| !authorization.StartsWith(prefix, StringComparison.Ordinal)
|| authorization.Length - prefix.Length is < 32 or > 128)
{
return false;
}
byte[] supplied = Encoding.UTF8.GetBytes(authorization[prefix.Length..]);
try
{
return supplied.Length == expected.Length
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
}
finally
{
CryptographicOperations.ZeroMemory(supplied);
}
}
public void Dispose()
{
byte[]? token = Interlocked.Exchange(ref _token, null);
if (token is not null)
{
CryptographicOperations.ZeroMemory(token);
}
}
public override string ToString() => "[MetricsAccessCredential: REDACTED]";
}
internal static class PrometheusMetricsEndpoint
{
public static IEndpointRouteBuilder MapPrometheusMetricsEndpoint(
this IEndpointRouteBuilder endpoints,
PrometheusMetricsOptions options,
MetricsAccessCredential? credential)
{
endpoints.MapGet("/metrics", (HttpContext context, RendezvousTelemetry telemetry) =>
Export(context, telemetry, options, credential))
.WithName("MetricsScrape")
.ExcludeFromDescription();
return endpoints;
}
private static IResult Export(
HttpContext context,
RendezvousTelemetry telemetry,
PrometheusMetricsOptions options,
MetricsAccessCredential? credential)
{
if (!options.Enabled)
{
return Results.NotFound();
}
if (credential is null || !credential.Authorizes(context.Request))
{
telemetry.RecordMetricsScrape("rejected");
return Results.NotFound();
}
telemetry.RecordMetricsScrape("accepted");
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
return Results.Text(
telemetry.RenderPrometheus(),
"text/plain; version=0.0.4; charset=utf-8",
Encoding.UTF8);
}
}
@@ -1,5 +1,10 @@
using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
using System.Globalization;
using System.Text;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Observability; namespace FinalFactory.Rendezvous.Server.Observability;
@@ -10,6 +15,10 @@ internal sealed class RendezvousTelemetry : IDisposable
public const string ActivitySourceName = "FinalFactory.Rendezvous.Server"; public const string ActivitySourceName = "FinalFactory.Rendezvous.Server";
private readonly InMemoryEphemeralRendezvousStore _store; private readonly InMemoryEphemeralRendezvousStore _store;
private readonly SessionChangeJournal? _sessionChanges;
private readonly ProvisioningRuntime? _provisioning;
private readonly ConcurrentDictionary<MetricSeriesKey, long> _prometheusCounters = new();
private readonly ConcurrentDictionary<MetricSeriesKey, PrometheusHistogram> _prometheusHistograms = new();
private readonly Meter _meter = new(MeterName, "1.0.0"); private readonly Meter _meter = new(MeterName, "1.0.0");
private readonly ActivitySource _activities = new(ActivitySourceName, "1.0.0"); private readonly ActivitySource _activities = new(ActivitySourceName, "1.0.0");
private readonly Counter<long> _httpRequests; private readonly Counter<long> _httpRequests;
@@ -22,9 +31,14 @@ internal sealed class RendezvousTelemetry : IDisposable
private readonly Counter<long> _operatorAuthentication; private readonly Counter<long> _operatorAuthentication;
private readonly Histogram<double> _pairingLatency; private readonly Histogram<double> _pairingLatency;
public RendezvousTelemetry(InMemoryEphemeralRendezvousStore store) public RendezvousTelemetry(
InMemoryEphemeralRendezvousStore store,
SessionChangeJournal? sessionChanges = null,
ProvisioningRuntime? provisioning = null)
{ {
_store = store; _store = store;
_sessionChanges = sessionChanges;
_provisioning = provisioning;
_httpRequests = _meter.CreateCounter<long>("rendezvous.http.requests"); _httpRequests = _meter.CreateCounter<long>("rendezvous.http.requests");
_httpDuration = _meter.CreateHistogram<double>( _httpDuration = _meter.CreateHistogram<double>(
"rendezvous.http.duration", "rendezvous.http.duration",
@@ -75,9 +89,21 @@ internal sealed class RendezvousTelemetry : IDisposable
}; };
_httpRequests.Add(1, tags); _httpRequests.Add(1, tags);
_httpDuration.Record(elapsedMilliseconds, tags); _httpDuration.Record(elapsedMilliseconds, tags);
IncrementPrometheus(
"rendezvous_http_requests_total",
Labels(("operation", operation), ("status_code", statusCode.ToString(CultureInfo.InvariantCulture))));
ObservePrometheus(
"rendezvous_http_duration_milliseconds",
elapsedMilliseconds,
Labels(("operation", operation), ("status_code", statusCode.ToString(CultureInfo.InvariantCulture))));
} }
public void RecordUdp(string operation, string result, double elapsedMilliseconds) public void RecordUdp(
string operation,
string result,
double elapsedMilliseconds,
int receivedBytes = 0,
int responseBudgetBytes = 0)
{ {
TagList tags = new() TagList tags = new()
{ {
@@ -86,41 +112,315 @@ internal sealed class RendezvousTelemetry : IDisposable
}; };
_udpResults.Add(1, tags); _udpResults.Add(1, tags);
_udpDuration.Record(elapsedMilliseconds, tags); _udpDuration.Record(elapsedMilliseconds, tags);
IncrementPrometheus(
"rendezvous_udp_results_total",
Labels(("operation", operation), ("result", result)));
ObservePrometheus(
"rendezvous_udp_duration_milliseconds",
elapsedMilliseconds,
Labels(("operation", operation), ("result", result)));
AddPrometheus(
"rendezvous_udp_received_bytes_total",
Math.Max(0, receivedBytes),
Labels(("operation", operation)));
AddPrometheus(
"rendezvous_udp_response_budget_bytes_total",
Math.Max(0, responseBudgetBytes),
Labels(("operation", operation)));
} }
public void RecordLimiterDrop(string transport, string partition) => public void RecordLimiterDrop(string transport, string partition)
{
_limiterDrops.Add(1, new TagList _limiterDrops.Add(1, new TagList
{ {
{ "transport", transport }, { "transport", transport },
{ "partition", partition }, { "partition", partition },
}); });
IncrementPrometheus(
"rendezvous_limiter_drops_total",
Labels(("transport", transport), ("partition", partition)));
}
public void RecordAudit(string action, string result) => public void RecordAudit(string action, string result)
{
_auditEvents.Add(1, new TagList _auditEvents.Add(1, new TagList
{ {
{ "action", action }, { "action", action },
{ "result", result }, { "result", result },
}); });
IncrementPrometheus(
"rendezvous_audit_events_total",
Labels(("action", action), ("result", result)));
}
public void RecordConnectionOutcome(string outcome, string elapsedBucket) => public void RecordConnectionOutcome(string outcome, string elapsedBucket)
{
_connectionOutcomes.Add(1, new TagList _connectionOutcomes.Add(1, new TagList
{ {
{ "outcome", outcome }, { "outcome", outcome },
{ "elapsed_bucket", elapsedBucket }, { "elapsed_bucket", elapsedBucket },
}); });
IncrementPrometheus(
"rendezvous_connection_outcomes_total",
Labels(("outcome", outcome), ("elapsed_bucket", elapsedBucket)));
}
public void RecordOperatorAuthentication(string result) => public void RecordOperatorAuthentication(string result)
{
_operatorAuthentication.Add(1, new TagList _operatorAuthentication.Add(1, new TagList
{ {
{ "result", result }, { "result", result },
}); });
IncrementPrometheus(
"rendezvous_operator_authentication_total",
Labels(("result", result)));
}
public void RecordPairingLatency(double elapsedMilliseconds) => public void RecordPairingLatency(double elapsedMilliseconds)
{
_pairingLatency.Record(elapsedMilliseconds); _pairingLatency.Record(elapsedMilliseconds);
ObservePrometheus("rendezvous_pairing_latency_milliseconds", elapsedMilliseconds, string.Empty);
}
public void RecordMetricsScrape(string result) => IncrementPrometheus(
"rendezvous_metrics_scrapes_total",
Labels(("result", result)));
public string RenderPrometheus()
{
StringBuilder output = new(16 * 1024);
foreach (IGrouping<string, KeyValuePair<MetricSeriesKey, long>> family in _prometheusCounters
.OrderBy(static item => item.Key.Name, StringComparer.Ordinal)
.ThenBy(static item => item.Key.Labels, StringComparer.Ordinal)
.GroupBy(static item => item.Key.Name, StringComparer.Ordinal))
{
output.Append("# TYPE ").Append(family.Key).Append(" counter\n");
foreach (KeyValuePair<MetricSeriesKey, long> series in family)
{
AppendValue(output, series.Key.Name, series.Key.Labels, series.Value);
}
}
foreach (IGrouping<string, KeyValuePair<MetricSeriesKey, PrometheusHistogram>> family in
_prometheusHistograms
.OrderBy(static item => item.Key.Name, StringComparer.Ordinal)
.ThenBy(static item => item.Key.Labels, StringComparer.Ordinal)
.GroupBy(static item => item.Key.Name, StringComparer.Ordinal))
{
output.Append("# TYPE ").Append(family.Key).Append(" histogram\n");
foreach (KeyValuePair<MetricSeriesKey, PrometheusHistogram> series in family)
{
series.Value.Append(output, series.Key.Name, series.Key.Labels);
}
}
EphemeralStoreSnapshot store = _store.GetMetricsSnapshot();
AppendGauge(output, "rendezvous_store_active_listings", store.ActiveListings);
AppendGauge(output, "rendezvous_store_fresh_presence_bindings", store.FreshPresenceBindings);
AppendGauge(
output,
"rendezvous_store_awaiting_presence_listings",
Math.Max(0, store.ActiveListings - store.FreshPresenceBindings));
AppendGauge(output, "rendezvous_store_active_leases", store.ActiveListings);
AppendGauge(output, "rendezvous_store_active_attempts", store.ActiveJoinAttempts);
AppendGauge(output, "rendezvous_queue_depth", store.ActiveJoinAttempts);
AppendGauge(output, "rendezvous_store_replay_markers", store.ReplayMarkers);
AppendGauge(output, "rendezvous_store_available", store.IsAvailable ? 1 : 0);
AppendGauge(output, "rendezvous_store_draining", store.IsDraining ? 1 : 0);
AppendCounter(output, "rendezvous_store_expiry_churn_total", store.ExpiryChurn);
if (_sessionChanges is not null)
{
AppendGauge(output, "rendezvous_browser_sse_subscribers", _sessionChanges.SubscriberCount);
AppendGauge(output, "rendezvous_browser_sse_tenants", _sessionChanges.SubscribedTenantCount);
AppendGauge(output, "rendezvous_browser_replay_entries", _sessionChanges.ReplayCount);
}
AppendProcessMetrics(output);
AppendSigningKeyMetrics(output);
return output.ToString();
}
private void AppendSigningKeyMetrics(StringBuilder output)
{
if (_provisioning is null)
{
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
SigningKeyStatus[] statuses = _provisioning.SigningKeys.GetStatuses(now).ToArray();
output.Append("# TYPE rendezvous_signing_keys gauge\n");
foreach (IGrouping<string, SigningKeyStatus> state in statuses.GroupBy(
static status => status.Status,
StringComparer.Ordinal))
{
AppendValue(
output,
"rendezvous_signing_keys",
Labels(("state", state.Key)),
state.Count());
}
double seconds = statuses
.Where(static status => status.Status == "signing")
.Select(status => Math.Max(0, (status.SignUntil - now).TotalSeconds))
.DefaultIfEmpty(0)
.Min();
AppendGauge(output, "rendezvous_signing_key_sign_seconds_remaining", seconds);
}
private static void AppendProcessMetrics(StringBuilder output)
{
using Process process = Process.GetCurrentProcess();
process.Refresh();
AppendCounter(output, "process_cpu_seconds_total", process.TotalProcessorTime.TotalSeconds);
AppendGauge(output, "process_resident_memory_bytes", process.WorkingSet64);
AppendGauge(output, "process_virtual_memory_bytes", process.VirtualMemorySize64);
AppendGauge(output, "process_threads", process.Threads.Count);
try
{
AppendGauge(
output,
"process_open_file_descriptors",
Directory.EnumerateFileSystemEntries("/proc/self/fd").Count());
}
catch (Exception exception) when (exception is IOException
or UnauthorizedAccessException)
{
}
AppendGauge(output, "dotnet_gc_heap_size_bytes", GC.GetTotalMemory(forceFullCollection: false));
output.Append("# TYPE dotnet_gc_collections_total counter\n");
AppendValue(output, "dotnet_gc_collections_total", Labels(("generation", "0")), GC.CollectionCount(0));
AppendValue(output, "dotnet_gc_collections_total", Labels(("generation", "1")), GC.CollectionCount(1));
AppendValue(output, "dotnet_gc_collections_total", Labels(("generation", "2")), GC.CollectionCount(2));
AppendGauge(output, "dotnet_thread_pool_threads", ThreadPool.ThreadCount);
ThreadPool.GetAvailableThreads(out int workerThreads, out int completionThreads);
AppendGauge(output, "dotnet_thread_pool_available_worker_threads", workerThreads);
AppendGauge(output, "dotnet_thread_pool_available_completion_threads", completionThreads);
}
private void IncrementPrometheus(string name, string labels) =>
_prometheusCounters.AddOrUpdate(new(name, labels), 1, static (_, current) => current + 1);
private void AddPrometheus(string name, long value, string labels)
{
if (value <= 0)
{
return;
}
_prometheusCounters.AddOrUpdate(new(name, labels), value, (_, current) => current + value);
}
private void ObservePrometheus(string name, double value, string labels) =>
_prometheusHistograms.GetOrAdd(new(name, labels), static _ => new()).Observe(value);
private static string Labels(params (string Name, string Value)[] labels)
{
if (labels.Length == 0)
{
return string.Empty;
}
return "{" + string.Join(',', labels.Select(static label =>
$"{label.Name}=\"{EscapeLabel(NormalizeLabel(label.Value))}\"")) + "}";
}
private static string NormalizeLabel(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 64)
{
return "other";
}
return value.All(static character => char.IsAsciiLetterOrDigit(character)
|| character is '-' or '_' or '.')
? value
: "other";
}
private static string EscapeLabel(string value) => value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal);
private static void AppendCounter(
StringBuilder output,
string name,
double value,
string labels = "")
{
output.Append("# TYPE ").Append(name).Append(" counter\n");
AppendValue(output, name, labels, value);
}
private static void AppendGauge(
StringBuilder output,
string name,
double value,
string labels = "")
{
output.Append("# TYPE ").Append(name).Append(" gauge\n");
AppendValue(output, name, labels, value);
}
private static void AppendValue(StringBuilder output, string name, string labels, double value) =>
output.Append(name)
.Append(labels)
.Append(' ')
.Append(value.ToString("R", CultureInfo.InvariantCulture))
.Append('\n');
public void Dispose() public void Dispose()
{ {
_activities.Dispose(); _activities.Dispose();
_meter.Dispose(); _meter.Dispose();
} }
private readonly record struct MetricSeriesKey(string Name, string Labels);
private sealed class PrometheusHistogram
{
private static readonly double[] Bounds = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000];
private readonly object _gate = new();
private readonly long[] _buckets = new long[Bounds.Length];
private long _count;
private double _sum;
public void Observe(double value)
{
if (!double.IsFinite(value) || value < 0)
{
return;
}
lock (_gate)
{
_count++;
_sum += value;
for (int index = 0; index < Bounds.Length; index++)
{
if (value <= Bounds[index])
{
_buckets[index]++;
}
}
}
}
public void Append(StringBuilder output, string name, string labels)
{
lock (_gate)
{
for (int index = 0; index < Bounds.Length; index++)
{
AppendValue(
output,
name + "_bucket",
AddLabel(labels, "le", Bounds[index].ToString("R", CultureInfo.InvariantCulture)),
_buckets[index]);
}
AppendValue(output, name + "_bucket", AddLabel(labels, "le", "+Inf"), _count);
AppendValue(output, name + "_sum", labels, _sum);
AppendValue(output, name + "_count", labels, _count);
}
}
private static string AddLabel(string labels, string name, string value) =>
string.IsNullOrEmpty(labels)
? $"{{{name}=\"{value}\"}}"
: labels[..^1] + $",{name}=\"{value}\"}}";
}
} }
@@ -3,6 +3,7 @@ namespace FinalFactory.Rendezvous.Server.Operations;
internal sealed record OperatorStatusResponse internal sealed record OperatorStatusResponse
{ {
public required string Status { get; init; } public required string Status { get; init; }
public required OperatorCompatibilityResponse Compatibility { get; init; }
public required OperatorReadinessResponse Readiness { get; init; } public required OperatorReadinessResponse Readiness { get; init; }
public required OperatorStoreResponse Store { get; init; } public required OperatorStoreResponse Store { get; init; }
public required IReadOnlyList<OperatorTenantResponse> Tenants { get; init; } public required IReadOnlyList<OperatorTenantResponse> Tenants { get; init; }
@@ -10,6 +11,18 @@ internal sealed record OperatorStatusResponse
public required IReadOnlyDictionary<string, long> AuditCounts { get; init; } public required IReadOnlyDictionary<string, long> AuditCounts { get; init; }
} }
internal sealed record OperatorCompatibilityResponse
{
public required string ServerVersion { get; init; }
public required string MinimumClientVersion { get; init; }
public required int MaximumClientMajorVersion { get; init; }
public required IReadOnlyList<int> HttpContractVersions { get; init; }
public required IReadOnlyList<int> UdpContractVersions { get; init; }
public required IReadOnlyList<int> ConnectionTicketFormatVersions { get; init; }
public required int LiteNetLibMajorVersion { get; init; }
public required string GameplayProtocolCompatibility { get; init; }
}
internal sealed record OperatorReadinessResponse internal sealed record OperatorReadinessResponse
{ {
public required bool HttpListener { get; init; } public required bool HttpListener { get; init; }
@@ -19,6 +19,7 @@ internal sealed class OperatorService(
return new OperatorStatusResponse return new OperatorStatusResponse
{ {
Status = readinessSnapshot.IsReady ? "ready" : "not-ready", Status = readinessSnapshot.IsReady ? "ready" : "not-ready",
Compatibility = ReleaseCompatibility.CreateResponse(),
Readiness = new OperatorReadinessResponse Readiness = new OperatorReadinessResponse
{ {
HttpListener = readinessSnapshot.HttpListenerReady, HttpListener = readinessSnapshot.HttpListenerReady,
@@ -0,0 +1,41 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Operations;
internal static class ReleaseCompatibility
{
private static readonly Assembly ServerAssembly = typeof(ReleaseCompatibility).Assembly;
internal static string MinimumClientVersion => Metadata("RendezvousMinimumClientVersion");
internal static int MaximumClientMajorVersion => MetadataInteger("RendezvousMaximumClientMajorVersion");
internal static int UdpContractVersion => MetadataInteger("RendezvousUdpContractVersion");
internal static int ConnectionTicketFormatVersion => MetadataInteger("RendezvousConnectionTicketFormatVersion");
internal static int LiteNetLibMajorVersion => MetadataInteger("RendezvousLiteNetLibMajorVersion");
internal static OperatorCompatibilityResponse CreateResponse() => new()
{
ServerVersion = ServerAssembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion.Split('+', 2)[0]
?? ServerAssembly.GetName().Version?.ToString(3)
?? "unknown",
MinimumClientVersion = MinimumClientVersion,
MaximumClientMajorVersion = MaximumClientMajorVersion,
HttpContractVersions = [ContractLimits.ContractVersion],
UdpContractVersions = [UdpContractVersion],
ConnectionTicketFormatVersions = [ConnectionTicketFormatVersion],
LiteNetLibMajorVersion = LiteNetLibMajorVersion,
GameplayProtocolCompatibility = "exact-per-tenant",
};
private static string Metadata(string key) => ServerAssembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(attribute => string.Equals(attribute.Key, key, StringComparison.Ordinal))
.Value
?? throw new InvalidOperationException($"Assembly metadata {key} has no value.");
private static int MetadataInteger(string key) => int.Parse(
Metadata(key),
System.Globalization.CultureInfo.InvariantCulture);
}
+40 -1
View File
@@ -4,6 +4,7 @@ using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.Deployment; using FinalFactory.Rendezvous.Server.Deployment;
using FinalFactory.Rendezvous.Server.Diagnostics;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Observability; using FinalFactory.Rendezvous.Server.Observability;
@@ -251,10 +252,42 @@ if (!builder.Environment.IsDevelopment() && !isOpenApiGeneration)
} }
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(deploymentOptions)); builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(deploymentOptions));
DiagnosticDashboardOptions diagnosticDashboardOptions = builder.Configuration
.GetSection(DiagnosticDashboardOptions.SectionName)
.Get<DiagnosticDashboardOptions>() ?? new DiagnosticDashboardOptions();
IReadOnlyList<string> diagnosticErrors = diagnosticDashboardOptions.Validate();
if (diagnosticErrors.Count > 0)
{
throw new DeploymentConfigurationException(diagnosticErrors);
}
builder.Services.AddSingleton(
Microsoft.Extensions.Options.Options.Create(diagnosticDashboardOptions));
PrometheusMetricsOptions metricsOptions = builder.Configuration
.GetSection(PrometheusMetricsOptions.SectionName)
.Get<PrometheusMetricsOptions>() ?? new PrometheusMetricsOptions();
IReadOnlyList<string> metricsErrors = metricsOptions.Validate();
if (metricsErrors.Count > 0)
{
throw new DeploymentConfigurationException(metricsErrors);
}
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(metricsOptions));
MetricsAccessCredential? metricsCredential = null;
if (metricsOptions.Enabled && !isOpenApiGeneration)
{
EnvironmentSecretProvider metricsSecrets = new();
if (!MetricsAccessCredential.TryCreate(metricsOptions, metricsSecrets, out metricsCredential)
|| metricsCredential is null)
{
throw new DeploymentConfigurationException(
[$"{PrometheusMetricsOptions.SectionName}:BearerTokenSecretReference did not resolve to 32-128 visible ASCII bytes."]);
}
builder.Services.AddSingleton(metricsCredential);
}
builder.Services.Configure<HostOptions>(options => builder.Services.Configure<HostOptions>(options =>
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10)); options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
SystemRendezvousClock rendezvousClock = new(); SystemRendezvousClock rendezvousClock = new();
SessionChangeJournal sessionChanges = new(new SessionChangeJournalOptions());
EphemeralStoreOptions stateOptions = new() EphemeralStoreOptions stateOptions = new()
{ {
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds), GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
@@ -262,8 +295,10 @@ EphemeralStoreOptions stateOptions = new()
InMemoryEphemeralRendezvousStore stateStore = new( InMemoryEphemeralRendezvousStore stateStore = new(
stateOptions, stateOptions,
rendezvousClock, rendezvousClock,
rendezvousClock); rendezvousClock,
sessionChanges);
builder.Services.AddSingleton(stateStore); builder.Services.AddSingleton(stateStore);
builder.Services.AddSingleton(sessionChanges);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore); builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
builder.Services.AddSingleton<IWallClock>(rendezvousClock); builder.Services.AddSingleton<IWallClock>(rendezvousClock);
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock); builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
@@ -297,7 +332,9 @@ else
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>(); builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>(); builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionStreamCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<SessionStreamService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>(); builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>(); builder.Services.AddSingleton<JoinAttemptService>();
builder.Services.AddSingleton<ConnectionOutcomeMetrics>(); builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
@@ -344,6 +381,8 @@ app.MapOpenApi();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
app.MapOperatorEndpoints(); app.MapOperatorEndpoints();
app.MapRendezvousHealthEndpoints(); app.MapRendezvousHealthEndpoints();
app.MapDiagnosticDashboardEndpoints();
app.MapPrometheusMetricsEndpoint(metricsOptions, metricsCredential);
await app.RunAsync(); await app.RunAsync();
@@ -240,7 +240,15 @@ internal sealed class SessionLeaseService(
} }
StoredListing ownedListing = listing!; StoredListing ownedListing = listing!;
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata); PublisherAuthorizationResult authorized = authorization.Authorize(
principal,
ownedListing.Definition.Scope.GameId,
ownedListing.Definition.Scope.EnvironmentId,
request.RegionId ?? ownedListing.Definition.RegionId,
request.ProtocolVersion ?? ownedListing.Definition.ProtocolVersion,
request.Visibility ?? ownedListing.Definition.Visibility,
request.Metadata,
clock.UtcNow);
if (!authorized.IsAllowed || authorized.Context is null) if (!authorized.IsAllowed || authorized.Context is null)
{ {
return new(MapAuthorization(authorized.Error)); return new(MapAuthorization(authorized.Error));
@@ -261,7 +269,10 @@ internal sealed class SessionLeaseService(
request.Capacity.CurrentPlayers, request.Capacity.CurrentPlayers,
request.Capacity.MaximumPlayers, request.Capacity.MaximumPlayers,
request.Metadata, request.Metadata,
request.DedicatedFallback), cancellationToken); request.DedicatedFallback,
request.RegionId,
request.ProtocolVersion,
request.Visibility), cancellationToken);
return updated.Succeeded return updated.Succeeded
? new(RendezvousErrorCode.None, true) ? new(RendezvousErrorCode.None, true)
: new(updated.Code.ToContractError()); : new(updated.Code.ToContractError());
@@ -391,6 +402,9 @@ internal sealed class SessionLeaseService(
|| !ContractValidation.IsDisplayNameValid(request.DisplayName) || !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata) || !ContractValidation.IsMetadataValid(request.Metadata)
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
|| request.ProtocolVersion.HasValue && request.ProtocolVersion.Value == 0
|| request.Visibility.HasValue && !Enum.IsDefined(request.Visibility.Value)
|| request.DedicatedFallback is not null || request.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback) && !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
? RendezvousErrorCode.InvalidRequest ? RendezvousErrorCode.InvalidRequest
@@ -228,7 +228,10 @@ internal sealed record UpdateListingCommand(
int CurrentPlayers, int CurrentPlayers,
int MaximumPlayers, int MaximumPlayers,
IReadOnlyDictionary<string, string> Metadata, IReadOnlyDictionary<string, string> Metadata,
NetworkEndpoint? DedicatedFallback); NetworkEndpoint? DedicatedFallback,
RegionId? RegionId = null,
uint? ProtocolVersion = null,
ListingVisibility? Visibility = null);
internal sealed record DeleteListingCommand( internal sealed record DeleteListingCommand(
SessionListingId ListingId, SessionListingId ListingId,
@@ -1,4 +1,5 @@
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
namespace FinalFactory.Rendezvous.Server.State; namespace FinalFactory.Rendezvous.Server.State;
@@ -8,6 +9,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private readonly object _gate = new(); private readonly object _gate = new();
private readonly EphemeralStoreOptions _options; private readonly EphemeralStoreOptions _options;
private readonly IMonotonicClock _monotonicClock; private readonly IMonotonicClock _monotonicClock;
private readonly SessionChangeJournal? _sessionChanges;
private readonly DateTimeOffset _wallOrigin; private readonly DateTimeOffset _wallOrigin;
private readonly TimeSpan _monotonicOrigin; private readonly TimeSpan _monotonicOrigin;
private readonly Dictionary<SessionListingId, ListingEntry> _listings = []; private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
@@ -45,7 +47,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
public InMemoryEphemeralRendezvousStore( public InMemoryEphemeralRendezvousStore(
EphemeralStoreOptions options, EphemeralStoreOptions options,
IWallClock wallClock, IWallClock wallClock,
IMonotonicClock monotonicClock) IMonotonicClock monotonicClock,
SessionChangeJournal? sessionChanges = null)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(wallClock); ArgumentNullException.ThrowIfNull(wallClock);
@@ -53,6 +56,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
options.Validate(); options.Validate();
_options = options; _options = options;
_monotonicClock = monotonicClock; _monotonicClock = monotonicClock;
_sessionChanges = sessionChanges;
_wallOrigin = wallClock.UtcNow; _wallOrigin = wallClock.UtcNow;
_monotonicOrigin = monotonicClock.Elapsed; _monotonicOrigin = monotonicClock.Elapsed;
InstanceId = Guid.NewGuid(); InstanceId = Guid.NewGuid();
@@ -225,7 +229,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
now + _options.IdempotencyLifetime); now + _options.IdempotencyLifetime);
_idempotency.Add(idempotencyKey, idempotency); _idempotency.Add(idempotencyKey, idempotency);
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline); EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
return new(StoreResultCode.Success, Snapshot(entry)); StoredListing created = Snapshot(entry);
_sessionChanges?.Publish(null, created);
return new(StoreResultCode.Success, created);
}, cancellationToken); }, cancellationToken);
public StoreResult<StoredListing> RenewLease( public StoreResult<StoredListing> RenewLease(
@@ -277,6 +283,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers || command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|| command.CurrentPlayers < 0 || command.CurrentPlayers < 0
|| command.CurrentPlayers > command.MaximumPlayers || command.CurrentPlayers > command.MaximumPlayers
|| command.RegionId.HasValue && string.IsNullOrEmpty(command.RegionId.Value.Value)
|| command.ProtocolVersion.HasValue && command.ProtocolVersion.Value == 0
|| command.Visibility.HasValue && !Enum.IsDefined(command.Visibility.Value)
|| !ContractValidation.IsMetadataValid(command.Metadata) || !ContractValidation.IsMetadataValid(command.Metadata)
|| command.DedicatedFallback is not null || command.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback)) && !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
@@ -302,8 +311,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.NotFound); return new(StoreResultCode.NotFound);
} }
StoredListing before = Snapshot(entry);
entry.Definition = StoredListing.Freeze(entry.Definition with entry.Definition = StoredListing.Freeze(entry.Definition with
{ {
RegionId = command.RegionId ?? entry.Definition.RegionId,
ProtocolVersion = command.ProtocolVersion ?? entry.Definition.ProtocolVersion,
Visibility = command.Visibility ?? entry.Definition.Visibility,
BuildVersion = command.BuildVersion, BuildVersion = command.BuildVersion,
DisplayName = command.DisplayName, DisplayName = command.DisplayName,
CurrentPlayers = command.CurrentPlayers, CurrentPlayers = command.CurrentPlayers,
@@ -312,7 +325,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
DedicatedFallback = command.DedicatedFallback, DedicatedFallback = command.DedicatedFallback,
}); });
entry.Version++; entry.Version++;
return new(StoreResultCode.Success, Snapshot(entry)); StoredListing after = Snapshot(entry);
_sessionChanges?.Publish(before, after);
return new(StoreResultCode.Success, after);
}, cancellationToken); }, cancellationToken);
public StoreResult<bool> DeleteListing( public StoreResult<bool> DeleteListing(
@@ -382,6 +397,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.CapacityExceeded); return new(StoreResultCode.CapacityExceeded);
} }
StoredListing before = Snapshot(entry);
bool isNewPresence = !_presence.ContainsKey(command.Handle); bool isNewPresence = !_presence.ContainsKey(command.Handle);
PresenceEntry presence = new( PresenceEntry presence = new(
command.PublicEndpoint, command.PublicEndpoint,
@@ -396,7 +412,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
command.Handle, command.Handle,
presence.Deadline); presence.Deadline);
} }
return new(StoreResultCode.Success, Snapshot(entry)); StoredListing after = Snapshot(entry);
_sessionChanges?.Publish(before, after);
return new(StoreResultCode.Success, after);
}, cancellationToken, eagerCleanup: false); }, cancellationToken, eagerCleanup: false);
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings( public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
@@ -996,6 +1014,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private void ClearActiveState() private void ClearActiveState()
{ {
StoredListing[] removedListings = _listings.Values.Select(Snapshot).ToArray();
_listings.Clear(); _listings.Clear();
_listingCountsByOwner.Clear(); _listingCountsByOwner.Clear();
_listingExpiries.Clear(); _listingExpiries.Clear();
@@ -1017,6 +1036,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
_idempotencyExpiries.Clear(); _idempotencyExpiries.Clear();
_replay.Clear(); _replay.Clear();
_replayExpiries.Clear(); _replayExpiries.Clear();
foreach (StoredListing listing in removedListings)
{
_sessionChanges?.Publish(listing, null);
}
} }
private void RemoveListing(SessionListingId listingId) private void RemoveListing(SessionListingId listingId)
@@ -1026,6 +1049,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return; return;
} }
StoredListing removed = Snapshot(listing);
_leases.Remove(listing.Definition.LeaseId); _leases.Remove(listing.Definition.LeaseId);
DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject); DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject);
_presenceHandles.Remove(listing.Definition.HostPresenceHandle); _presenceHandles.Remove(listing.Definition.HostPresenceHandle);
@@ -1045,6 +1069,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
RemoveOutcome(attemptId); RemoveOutcome(attemptId);
} }
} }
_sessionChanges?.Publish(removed, null);
} }
private void RemoveAttempt(JoinAttemptId attemptId) private void RemoveAttempt(JoinAttemptId attemptId)
@@ -1186,6 +1212,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
else if (_presence.Remove(candidate.Key)) else if (_presence.Remove(candidate.Key))
{ {
if (_presenceHandles.TryGetValue(candidate.Key, out SessionListingId listingId)
&& _listings.TryGetValue(listingId, out ListingEntry? listing))
{
StoredListing after = Snapshot(listing);
_sessionChanges?.Publish(after with { HasFreshPresence = true }, after);
}
removed++; removed++;
} }
} }
@@ -82,7 +82,9 @@ internal sealed class NatMediationProcessor(
telemetry?.RecordUdp( telemetry?.RecordUdp(
"frozen", "frozen",
result.ToString(), result.ToString(),
Stopwatch.GetElapsedTime(started).TotalMilliseconds); Stopwatch.GetElapsedTime(started).TotalMilliseconds,
encoded.Length,
result == NatMediationResult.Introduced ? 2 * ContractLimits.UdpDatagramMaxBytes : 0);
return result; return result;
} }
@@ -157,7 +159,7 @@ internal sealed class NatMediationProcessor(
observedPublicEndpoint, observedPublicEndpoint,
token, token,
introductionSink, introductionSink,
cancellationToken); cancellationToken: cancellationToken);
} }
internal NatMediationResult ProcessRequestAfterIngress( internal NatMediationResult ProcessRequestAfterIngress(
@@ -165,6 +167,7 @@ internal sealed class NatMediationProcessor(
IPEndPoint observedPublicEndpoint, IPEndPoint observedPublicEndpoint,
string token, string token,
INatIntroductionSink introductionSink, INatIntroductionSink introductionSink,
int receivedBytes = 0,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
long started = Stopwatch.GetTimestamp(); long started = Stopwatch.GetTimestamp();
@@ -178,7 +181,9 @@ internal sealed class NatMediationProcessor(
telemetry?.RecordUdp( telemetry?.RecordUdp(
"litenet", "litenet",
result.ToString(), result.ToString(),
Stopwatch.GetElapsedTime(started).TotalMilliseconds); Stopwatch.GetElapsedTime(started).TotalMilliseconds,
receivedBytes,
result == NatMediationResult.Introduced ? 2 * ContractLimits.UdpDatagramMaxBytes : 0);
return result; return result;
} }
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Transport; namespace FinalFactory.Rendezvous.Server.Transport;
internal sealed partial class UdpMediatorService : BackgroundService internal sealed class UdpMediatorService : BackgroundService
{ {
private readonly ILogger<UdpMediatorService> _logger; private readonly ILogger<UdpMediatorService> _logger;
private readonly UdpMediatorOptions _options; private readonly UdpMediatorOptions _options;
@@ -133,20 +133,23 @@ internal sealed partial class UdpMediatorService : BackgroundService
manager?.Stop(); manager?.Stop();
} }
[LoggerMessage( private static readonly Action<ILogger, IPAddress, int, Exception?> MediatorListening =
EventId = 1, LoggerMessage.Define<IPAddress, int>(
Level = LogLevel.Information, LogLevel.Information,
Message = "UDP mediator listening on {ListenAddress}:{ListenPort}")] new EventId(1, nameof(LogMediatorListening)),
private static partial void LogMediatorListening( "UDP mediator listening on {ListenAddress}:{ListenPort}");
private static readonly Action<ILogger, Exception?> MediatorStopped = LoggerMessage.Define(
LogLevel.Information,
new EventId(2, nameof(LogMediatorStopped)),
"UDP mediator stopped");
private static void LogMediatorListening(
ILogger logger, ILogger logger,
IPAddress listenAddress, IPAddress listenAddress,
int listenPort); int listenPort) => MediatorListening(logger, listenAddress, listenPort, null);
[LoggerMessage( private static void LogMediatorStopped(ILogger logger) => MediatorStopped(logger, null);
EventId = 2,
Level = LogLevel.Information,
Message = "UDP mediator stopped")]
private static partial void LogMediatorStopped(ILogger logger);
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
{ {
@@ -198,7 +201,7 @@ internal sealed partial class UdpMediatorService : BackgroundService
&& token is not null) && token is not null)
{ {
_ = processor.ProcessRequestAfterIngress( _ = processor.ProcessRequestAfterIngress(
claimedLocalEndpoint, endPoint, token, sink); claimedLocalEndpoint, endPoint, token, sink, length);
} }
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions. // Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
@@ -4,7 +4,7 @@
"net10.0": { "net10.0": {
"LiteNetLib": { "LiteNetLib": {
"type": "Direct", "type": "Direct",
"requested": "[2.1.4, )", "requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4", "resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA==" "contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
}, },
@@ -13,13 +13,16 @@ authenticated direct peer, and answers a bounded ping/echo/ack/completion exchan
LiteNetLib socket, proves direct traffic, reports the typed outcome, and exits. LiteNetLib socket, proves direct traffic, reports the typed outcome, and exits.
Run `dotnet run --project src/FinalFactory.Rendezvous.TestClient -- --help` for Run `dotnet run --project src/FinalFactory.Rendezvous.TestClient -- --help` for
the complete option reference. A typical script-mode invocation is: the complete option reference. The repository's
[start-to-finish guide](../../docs/integration/test-client.md) provides an
executable local Compose setup, safe failure drill, JSON automation, and a
phase-by-phase diagnostic table. A typical deployment invocation is:
```bash ```bash
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<credential from the deployment boundary>' export RENDEZVOUS_PUBLISHER_CREDENTIAL='<credential from the deployment boundary>'
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \ dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \ host --service https://rendezvous.example/ --mediator rendezvous.example:9050 \
--game space-game --environment development --region local --protocol 1 \ --game space-game --environment production --region eu-central --protocol 1 \
--script --json --exit-after-echo --script --json --exit-after-echo
``` ```
@@ -21,6 +21,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
{ {
TestClientMode.Host => RunHostAsync(options, output, cancellationToken), TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken), TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
TestClientMode.Watch => RunWatchAsync(options, output, cancellationToken),
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken), TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
_ => Task.FromResult(TestClientExitCode.Usage), _ => Task.FromResult(TestClientExitCode.Usage),
}; };
@@ -114,11 +115,12 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
listingId: session.ListingId.ToString(), listingId: session.ListingId.ToString(),
displayName: options.DisplayName); displayName: options.DisplayName);
echo = new DirectEchoProtocol(events.GameplayEvents, host: true); echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
echo.ExchangeCompleted += _ => output.Write( echo.ExchangeCompleted += peer => output.Write(
"host.direct-traffic", "host.direct-traffic",
"verified", "verified",
phase: "direct-traffic", phase: "direct-traffic",
endpointType: "peer-to-peer"); endpointType: "peer-to-peer",
addressFamily: AddressFamilyName(peer.Address));
coordinator = new RendezvousHostCoordinator( coordinator = new RendezvousHostCoordinator(
manager, manager,
events, events,
@@ -485,6 +487,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
"connected", "connected",
phase: "direct-connection", phase: "direct-connection",
endpointType: endpointType, endpointType: endpointType,
addressFamily: AddressFamilyName(peer.Address),
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed)); elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false); await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
echo.BeginJoin(peer); echo.BeginJoin(peer);
@@ -507,7 +510,8 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
"join.direct-traffic", "join.direct-traffic",
"verified", "verified",
phase: "direct-traffic", phase: "direct-traffic",
endpointType: endpointType); endpointType: endpointType,
addressFamily: AddressFamilyName(peer.Address));
peer.Disconnect(); peer.Disconnect();
manager.PollEvents(); manager.PollEvents();
return TestClientExitCode.Success; return TestClientExitCode.Success;
@@ -550,6 +554,178 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
} }
} }
private static async Task<TestClientExitCode> RunWatchAsync(
TestClientOptions options,
TestClientOutput output,
CancellationToken cancellationToken)
{
using CancellationTokenSource watch = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
watch.CancelAfter(options.RunDuration ?? options.OperationTimeout);
using HttpClient http = CreateHttpClient(options);
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
BrowseSessionsRequest request = BrowseRequest(options);
RendezvousClientResult<BrowseSessionsResponse> snapshot = await browser.BrowseAsync(
request,
watch.Token).ConfigureAwait(false);
if (!snapshot.IsSuccess || snapshot.Value is null)
{
WriteServiceFailure(output, "watch.snapshot", "directory", snapshot);
return TestClientExitCode.ServiceFailure;
}
output.Write(
"watch.snapshot",
"complete",
phase: "directory",
count: snapshot.Value.Items.Count);
string cursor = options.ExerciseReset
? CorruptCursor(snapshot.Value.StreamCursor)
: snapshot.Value.StreamCursor;
output.Write("watch.stream", "started", phase: "live-directory");
SessionStreamEvent? expectedReplay = null;
bool reconnectExerciseCompleted = false;
int emptyConnections = 0;
try
{
while (true)
{
string connectionCursor = cursor;
bool receivedEvent = false;
bool deliberateReconnect = false;
await foreach (RendezvousClientResult<SessionStreamEvent> result in browser
.StreamAsync(request, cursor, watch.Token)
.ConfigureAwait(false))
{
if (!result.IsSuccess || result.Value is null)
{
WriteServiceFailure(output, "watch.stream", "live-directory", result);
return TestClientExitCode.ServiceFailure;
}
receivedEvent = true;
SessionStreamEvent item = result.Value;
if (expectedReplay is not null)
{
if (!SameStreamEvent(expectedReplay, item))
{
output.WriteError(
"watch.reconnect",
"failed",
"The reconnect did not replay the expected ordered event.",
phase: "live-directory");
return TestClientExitCode.ServiceFailure;
}
output.Write("watch.reconnect", "verified", phase: "live-directory");
expectedReplay = null;
reconnectExerciseCompleted = true;
cursor = item.Cursor;
if (options.Script)
{
return TestClientExitCode.Success;
}
}
else
{
cursor = item.Cursor;
}
switch (item.Kind)
{
case SessionStreamEventKind.SessionUpsert when item.Session is not null:
output.Write(
"watch.session-upsert",
"available",
phase: "live-directory",
listingId: item.Session.ListingId.ToString(),
displayName: item.Session.DisplayName);
break;
case SessionStreamEventKind.SessionRemove when item.ListingId.HasValue:
output.Write(
"watch.session-remove",
"removed",
phase: "live-directory",
listingId: item.ListingId.Value.ToString());
break;
case SessionStreamEventKind.Reset:
output.Write("watch.reset", "required", phase: "live-directory");
RendezvousClientResult<BrowseSessionsResponse> refreshed = await browser.BrowseAsync(
request,
watch.Token).ConfigureAwait(false);
if (!refreshed.IsSuccess || refreshed.Value is null)
{
WriteServiceFailure(output, "watch.snapshot", "directory", refreshed);
return TestClientExitCode.ServiceFailure;
}
output.Write(
"watch.snapshot",
"refreshed",
phase: "directory",
count: refreshed.Value.Items.Count);
return TestClientExitCode.Success;
case SessionStreamEventKind.Keepalive:
output.Write("watch.keepalive", "alive", phase: "live-directory");
break;
}
bool listingDelta = item.Kind is SessionStreamEventKind.SessionUpsert
or SessionStreamEventKind.SessionRemove;
if (options.ExerciseReconnect
&& !reconnectExerciseCompleted
&& listingDelta
&& expectedReplay is null)
{
expectedReplay = item;
cursor = connectionCursor;
deliberateReconnect = true;
output.Write("watch.reconnect", "started", phase: "live-directory");
break;
}
if (options.Script && listingDelta)
{
return TestClientExitCode.Success;
}
}
if (deliberateReconnect)
{
continue;
}
emptyConnections = receivedEvent ? 0 : emptyConnections + 1;
if (emptyConnections >= 3)
{
output.WriteError(
"watch.reconnect",
"failed",
"The stream closed repeatedly without an event; use bounded polling fallback.",
phase: "live-directory");
return TestClientExitCode.ServiceFailure;
}
output.Write("watch.reconnect", "required", phase: "live-directory");
await Task.Delay(TimeSpan.FromMilliseconds(250), watch.Token).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
output.Write("watch.complete", "complete", phase: "lifecycle");
return TestClientExitCode.Success;
}
}
private static bool SameStreamEvent(SessionStreamEvent expected, SessionStreamEvent actual) =>
expected.Kind == actual.Kind
&& string.Equals(expected.Cursor, actual.Cursor, StringComparison.Ordinal)
&& expected.ListingId == actual.ListingId
&& expected.Session?.ListingId == actual.Session?.ListingId;
private static string CorruptCursor(string cursor)
{
if (string.IsNullOrEmpty(cursor))
{
return "invalid-stream-cursor";
}
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
return cursor[..^1] + replacement;
}
private static async Task<SessionSelection> SelectListingAsync( private static async Task<SessionSelection> SelectListingAsync(
TestClientOptions options, TestClientOptions options,
TestClientOutput output, TestClientOutput output,
@@ -765,6 +941,9 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
return privateAddress ? "private" : "public"; return privateAddress ? "private" : "public";
} }
private static string AddressFamilyName(IPAddress address) =>
address.AddressFamily == AddressFamily.InterNetworkV6 ? "ipv6" : "ipv4";
private static long ToMilliseconds(TimeSpan elapsed) => private static long ToMilliseconds(TimeSpan elapsed) =>
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds)); (long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
@@ -8,6 +8,7 @@ internal enum TestClientMode
{ {
Host, Host,
Browse, Browse,
Watch,
Join, Join,
} }
@@ -34,6 +35,8 @@ internal sealed class TestClientOptions
internal bool Script { get; init; } internal bool Script { get; init; }
internal bool Json { get; init; } internal bool Json { get; init; }
internal bool ExitAfterEcho { get; init; } internal bool ExitAfterEcho { get; init; }
internal bool ExerciseReconnect { get; init; }
internal bool ExerciseReset { get; init; }
} }
internal sealed class TestClientParseResult internal sealed class TestClientParseResult
@@ -63,6 +66,7 @@ internal static class TestClientOptionParser
Usage: Usage:
rendezvous-test-client host [options] rendezvous-test-client host [options]
rendezvous-test-client browse [options] rendezvous-test-client browse [options]
rendezvous-test-client watch [options]
rendezvous-test-client join [options] rendezvous-test-client join [options]
Common options: Common options:
@@ -88,6 +92,11 @@ internal static class TestClientOptionParser
--run-seconds NUMBER Stop after 1-86400 seconds --run-seconds NUMBER Stop after 1-86400 seconds
--exit-after-echo Stop after an authenticated ping/echo/ack exchange --exit-after-echo Stop after an authenticated ping/echo/ack exchange
Watch options:
--run-seconds NUMBER Stop after 1-86400 seconds
--exercise-reconnect Disconnect after an update and verify ordered replay
--exercise-reset Corrupt the snapshot cursor and verify reset/refresh
Join options: Join options:
--listing UUID Join an exact listing; otherwise browse/select --listing UUID Join an exact listing; otherwise browse/select
@@ -129,6 +138,8 @@ internal static class TestClientOptionParser
bool script = false; bool script = false;
bool json = false; bool json = false;
bool exitAfterEcho = false; bool exitAfterEcho = false;
bool exerciseReconnect = false;
bool exerciseReset = false;
HashSet<string> seen = new(StringComparer.Ordinal); HashSet<string> seen = new(StringComparer.Ordinal);
for (int index = 1; index < args.Length; index++) for (int index = 1; index < args.Length; index++)
@@ -138,7 +149,8 @@ internal static class TestClientOptionParser
{ {
return TestClientParseResult.Help(); return TestClientParseResult.Help();
} }
if (option is "--script" or "--json" or "--exit-after-echo") if (option is "--script" or "--json" or "--exit-after-echo"
or "--exercise-reconnect" or "--exercise-reset")
{ {
if (!seen.Add(option)) if (!seen.Add(option))
{ {
@@ -147,6 +159,8 @@ internal static class TestClientOptionParser
script |= option == "--script"; script |= option == "--script";
json |= option == "--json"; json |= option == "--json";
exitAfterEcho |= option == "--exit-after-echo"; exitAfterEcho |= option == "--exit-after-echo";
exerciseReconnect |= option == "--exercise-reconnect";
exerciseReset |= option == "--exercise-reset";
continue; continue;
} }
if (!option.StartsWith("--", StringComparison.Ordinal) if (!option.StartsWith("--", StringComparison.Ordinal)
@@ -279,8 +293,11 @@ internal static class TestClientOptionParser
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits."); return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
} }
if (listingId.HasValue && mode != TestClientMode.Join if (listingId.HasValue && mode != TestClientMode.Join
|| runSeconds.HasValue && mode != TestClientMode.Host || runSeconds.HasValue && mode is not (TestClientMode.Host or TestClientMode.Watch)
|| exitAfterEcho && mode != TestClientMode.Host || exitAfterEcho && mode != TestClientMode.Host
|| exerciseReconnect && mode != TestClientMode.Watch
|| exerciseReset && mode != TestClientMode.Watch
|| exerciseReconnect && exerciseReset
|| metadata.Count > 0 && mode != TestClientMode.Host || metadata.Count > 0 && mode != TestClientMode.Host
|| dedicatedFallback is not null && mode != TestClientMode.Host || dedicatedFallback is not null && mode != TestClientMode.Host
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host || seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
@@ -316,6 +333,8 @@ internal static class TestClientOptionParser
Script = script, Script = script,
Json = json, Json = json,
ExitAfterEcho = exitAfterEcho, ExitAfterEcho = exitAfterEcho,
ExerciseReconnect = exerciseReconnect,
ExerciseReset = exerciseReset,
}); });
} }
@@ -24,6 +24,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
string? displayName = null, string? displayName = null,
string? outcome = null, string? outcome = null,
string? endpointType = null, string? endpointType = null,
string? addressFamily = null,
int? count = null, int? count = null,
long? elapsedMilliseconds = null, long? elapsedMilliseconds = null,
string? message = null) => WriteCore( string? message = null) => WriteCore(
@@ -37,6 +38,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
DisplayName = SafeText(displayName), DisplayName = SafeText(displayName),
Outcome = SafeToken(outcome), Outcome = SafeToken(outcome),
EndpointType = SafeToken(endpointType), EndpointType = SafeToken(endpointType),
AddressFamily = SafeToken(addressFamily),
Count = count, Count = count,
ElapsedMilliseconds = elapsedMilliseconds, ElapsedMilliseconds = elapsedMilliseconds,
Message = SafeText(message), Message = SafeText(message),
@@ -92,6 +94,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
Append(line, "name", item.DisplayName, quote: true); Append(line, "name", item.DisplayName, quote: true);
Append(line, "outcome", item.Outcome); Append(line, "outcome", item.Outcome);
Append(line, "endpoint", item.EndpointType); Append(line, "endpoint", item.EndpointType);
Append(line, "addressFamily", item.AddressFamily);
if (item.Count.HasValue) if (item.Count.HasValue)
{ {
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
@@ -174,6 +177,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
public string? DisplayName { get; init; } public string? DisplayName { get; init; }
public string? Outcome { get; init; } public string? Outcome { get; init; }
public string? EndpointType { get; init; } public string? EndpointType { get; init; }
public string? AddressFamily { get; init; }
public int? Count { get; init; } public int? Count { get; init; }
public long? ElapsedMilliseconds { get; init; } public long? ElapsedMilliseconds { get; init; }
public string? Message { get; init; } public string? Message { get; init; }
@@ -4,7 +4,7 @@
"net8.0": { "net8.0": {
"LiteNetLib": { "LiteNetLib": {
"type": "Direct", "type": "Direct",
"requested": "[2.1.4, )", "requested": "[2.1.4, 2.1.4]",
"resolved": "2.1.4", "resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA==" "contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
}, },
@@ -22,7 +22,7 @@
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )", "FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )" "LiteNetLib": "[2.1.4, 2.1.4]"
} }
}, },
"finalfactory.rendezvous.contracts": { "finalfactory.rendezvous.contracts": {
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import {
SessionProjection,
buildBrowseUrl,
buildStreamUrl,
chooseSnapshotTransport,
safeText,
} from "../../src/FinalFactory.Rendezvous.Server/Diagnostics/Assets/app.mjs";
const cursor = "rvs1.test-cursor";
function session(id, name = `Host ${id}`) {
return {
contractVersion: 1,
listingId: `00000000-0000-0000-0000-${String(id).padStart(12, "0")}`,
gameId: "space-game",
environmentId: "smoke",
regionId: "local",
protocolVersion: 1,
buildVersion: "1.0.0",
displayName: name,
visibility: "public",
publisherTrustMode: "managedDedicated",
capacity: { currentPlayers: 1, maximumPlayers: 8 },
metadata: { mode: "online-coop" },
};
}
function event(kind, values = {}) {
return {
contractVersion: 1,
kind,
cursor: values.cursor ?? cursor,
session: values.session ?? null,
listingId: values.listingId ?? null,
};
}
test("snapshot and ordered deltas match the final public projection", () => {
const projection = new SessionProjection(10);
assert.equal(projection.replace([session(1)], cursor), "applied");
assert.equal(projection.apply(event("sessionUpsert", {
cursor: `${cursor}-2`,
session: session(2),
})), "applied");
assert.equal(projection.apply(event("sessionUpsert", {
cursor: `${cursor}-3`,
session: session(1, "Updated host"),
})), "applied");
assert.equal(projection.apply(event("sessionRemove", {
cursor: `${cursor}-4`,
listingId: session(2).listingId,
})), "applied");
assert.deepEqual(
projection.sessions().map((entry) => entry.session.displayName),
["Updated host"],
);
assert.equal(projection.cursor, `${cursor}-4`);
});
test("reset, malformed events, and a partial snapshot fail closed", () => {
const projection = new SessionProjection(2);
assert.equal(projection.replace([session(1)], cursor, true), "applied");
assert.equal(projection.apply(event("sessionRemove", {
listingId: session(1).listingId,
})), "refresh");
assert.equal(projection.apply(event("reset")), "reset");
assert.equal(projection.apply({ kind: "sessionUpsert" }), "invalid");
assert.equal(projection.replace([session(1), session(2), session(3)], cursor), "overflow");
});
test("bursts coalesce by listing identity and memory stays bounded", () => {
const projection = new SessionProjection(2);
assert.equal(projection.replace([], cursor), "applied");
for (let index = 0; index < 1_000; index += 1) {
assert.equal(projection.apply(event("sessionUpsert", {
cursor: `${cursor}-${index}`,
session: session(1, `Host ${index}`),
})), "applied");
}
assert.equal(projection.sessions().length, 1);
assert.equal(projection.sessions()[0].session.displayName, "Host 999");
assert.equal(projection.apply(event("sessionUpsert", { session: session(2) })), "applied");
assert.equal(projection.apply(event("sessionUpsert", { session: session(3) })), "overflow");
assert.equal(projection.sessions().length, 2);
});
test("requests are fixed same-origin paths with an exact configured filter", () => {
const filter = {
gameId: "space-game",
environmentId: "smoke",
protocolVersion: 1,
regionId: "local",
excludeFull: true,
};
const browse = buildBrowseUrl(filter);
const stream = buildStreamUrl(filter, cursor);
assert.match(browse, /^\/v1\/sessions\?/u);
assert.match(stream, /^\/v1\/sessions\/stream\?/u);
assert.match(browse, /gameId=space-game/u);
assert.match(browse, /environmentId=smoke/u);
assert.match(stream, /streamCursor=rvs1.test-cursor/u);
assert.doesNotMatch(browse, /https?:/u);
assert.doesNotMatch(stream, /https?:/u);
});
test("snapshot transport preserves deliberate polling and bounds partial snapshots", () => {
assert.equal(chooseSnapshotTransport(false, false), "stream");
assert.equal(chooseSnapshotTransport(true, false), "boundedPolling");
assert.equal(chooseSnapshotTransport(false, true), "pollingOnly");
assert.equal(chooseSnapshotTransport(true, true), "pollingOnly");
});
test("hostile display data remains literal text and no unsafe DOM sink exists", async () => {
const payload = `<img src=x onerror=alert(1)><style>body{display:none}</style><a href=//evil>go</a>`;
assert.equal(safeText(payload, 200), payload);
const source = await readFile(new URL(
"../../src/FinalFactory.Rendezvous.Server/Diagnostics/Assets/app.mjs",
import.meta.url,
), "utf8");
for (const forbidden of [
"innerHTML",
"outerHTML",
"insertAdjacentHTML",
"document.write",
"eval(",
"new Function",
"window.location",
]) {
assert.equal(source.includes(forbidden), false, `unsafe browser sink: ${forbidden}`);
}
});

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