Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bad659c12 | |||
| f368fec6eb | |||
| 9e863ebf64 | |||
| ebb5eb617c | |||
| 7fb85059fb | |||
| cc5793f935 | |||
| 07004cd75f | |||
| cf14836d48 | |||
| 609dad7cf1 | |||
| 08729ae25c | |||
| be732de7c9 |
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.idea
|
||||||
|
.vs
|
||||||
|
.codex
|
||||||
|
.agents
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
TestResults
|
||||||
|
deploy/compose/secrets
|
||||||
|
deploy/compose/.smoke.env
|
||||||
|
docs
|
||||||
|
tests
|
||||||
+83
-2
@@ -14,16 +14,34 @@ 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: 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
|
||||||
|
|
||||||
@@ -36,6 +54,9 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test Rendezvous.slnx --configuration Release --no-build
|
run: dotnet test Rendezvous.slnx --configuration Release --no-build
|
||||||
|
|
||||||
|
- name: Run quick capacity and resilience gate
|
||||||
|
run: ./scripts/run-capacity-gate.sh
|
||||||
|
|
||||||
- name: Test privileged Linux namespace topology when available
|
- name: Test privileged Linux namespace topology when available
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -88,3 +109,63 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Network namespaces/NAT tooling unavailable; deterministic loopback topology remains the required gate."
|
echo "Network namespaces/NAT tooling unavailable; deterministic loopback topology remains the required gate."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
container:
|
||||||
|
needs: quality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Install .NET SDK
|
||||||
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.301
|
||||||
|
|
||||||
|
- name: Build deployment diagnostic
|
||||||
|
run: |
|
||||||
|
dotnet restore src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj --locked-mode
|
||||||
|
dotnet build src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj --configuration Release --no-restore
|
||||||
|
|
||||||
|
- name: Build and exercise hardened container
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
compose_file="deploy/compose/compose.yaml"
|
||||||
|
secret="deploy/compose/secrets/signing-key"
|
||||||
|
cleanup() {
|
||||||
|
RENDEZVOUS_UID=1654 RENDEZVOUS_GID=1654 \
|
||||||
|
docker compose -f "$compose_file" down --volumes >/dev/null 2>&1 || true
|
||||||
|
rm -f "$secret"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
install -d -m 0700 deploy/compose/secrets
|
||||||
|
openssl rand -out "$secret" 32
|
||||||
|
chmod 0444 "$secret"
|
||||||
|
export RENDEZVOUS_UID=1654
|
||||||
|
export RENDEZVOUS_GID=1654
|
||||||
|
export SOURCE_REVISION_ID="$GITHUB_SHA"
|
||||||
|
docker compose -f "$compose_file" build \
|
||||||
|
--build-arg SOURCE_REVISION_ID="$SOURCE_REVISION_ID"
|
||||||
|
docker compose -f "$compose_file" up --no-build --detach
|
||||||
|
container_id="$(docker compose -f "$compose_file" ps -q rendezvous)"
|
||||||
|
test -n "$container_id"
|
||||||
|
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
|
||||||
|
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container_id")" = "true"
|
||||||
|
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/app/appsettings.Production.json\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
||||||
|
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/run/secrets/rendezvous-signing-key\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
||||||
|
for attempt in {1..100}; do
|
||||||
|
if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if (( attempt == 100 )); then
|
||||||
|
docker compose -f "$compose_file" logs rendezvous
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
./scripts/smoke-deployment.sh
|
||||||
|
docker compose -f "$compose_file" stop --timeout 40 rendezvous
|
||||||
|
test "$(docker inspect --format '{{.State.Running}}' "$container_id")" = "false"
|
||||||
|
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = "0"
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
name: immutable-release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ gitea.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- name: Check out immutable tag
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install pinned .NET SDK
|
||||||
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.301
|
||||||
|
|
||||||
|
- name: Install pinned Buildx and BuildKit
|
||||||
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
version: v0.35.0
|
||||||
|
install: true
|
||||||
|
driver-opts: image=moby/buildkit:v0.25.2@sha256:0f63d66f8d2de0bd16438284831a3e9ee6ca7cd57b6eb3ed6e38a7a456590fa7
|
||||||
|
|
||||||
|
- name: Validate tag and produce reproducible artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
./scripts/check-release-tag.sh "$GITHUB_REF_NAME"
|
||||||
|
previous_tag="$(git tag --merged HEAD^ --list 'v*.*.*' --sort=-version:refname | sed -n '1p')"
|
||||||
|
if [[ -n "$previous_tag" ]]; then
|
||||||
|
./scripts/check-compatibility.sh "$previous_tag"
|
||||||
|
elif [[ -n "$(git tag --list 'v*.*.*' | sed -n '1p')" ]]; then
|
||||||
|
echo "No prior release tag is an ancestor of $GITHUB_REF_NAME." >&2
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
./scripts/check-compatibility.sh __initial_release_without_base__
|
||||||
|
fi
|
||||||
|
release_builder="rendezvous-release-builder:${GITHUB_SHA}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--file eng/release-builder.Dockerfile \
|
||||||
|
--target release-builder \
|
||||||
|
--load \
|
||||||
|
--tag "$release_builder" .
|
||||||
|
mkdir -p "${RUNNER_TEMP}/release-home" "${RUNNER_TEMP}/nuget"
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--env HOME="${RUNNER_TEMP}/release-home" \
|
||||||
|
--env NUGET_PACKAGES="${RUNNER_TEMP}/nuget" \
|
||||||
|
--volume "$GITHUB_WORKSPACE:/source" \
|
||||||
|
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
|
||||||
|
--workdir /source \
|
||||||
|
"$release_builder" \
|
||||||
|
./scripts/build-release.sh "$version" "${RUNNER_TEMP}/release/$version"
|
||||||
|
./scripts/verify-real-consumers.sh "$version" "${RUNNER_TEMP}/release/$version"
|
||||||
|
echo "RENDEZVOUS_VERSION=$version" >>"$GITHUB_ENV"
|
||||||
|
echo "RENDEZVOUS_RELEASE_DIR=${RUNNER_TEMP}/release/$version" >>"$GITHUB_ENV"
|
||||||
|
echo "RENDEZVOUS_RELEASE_BUILDER=$release_builder" >>"$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build exact container candidate
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
|
||||||
|
common=(
|
||||||
|
--no-cache
|
||||||
|
--pull=false
|
||||||
|
--provenance=false
|
||||||
|
--platform linux/amd64
|
||||||
|
--build-arg SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH"
|
||||||
|
--build-arg SOURCE_REVISION_ID="$GITHUB_SHA"
|
||||||
|
)
|
||||||
|
release_tag="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
|
||||||
|
image_one="${RUNNER_TEMP}/rendezvous-image-1.tar"
|
||||||
|
image_two="${RUNNER_TEMP}/rendezvous-image-2.tar"
|
||||||
|
docker buildx build "${common[@]}" --tag "$release_tag" \
|
||||||
|
--output "type=docker,dest=$image_one,rewrite-timestamp=true" .
|
||||||
|
docker buildx build "${common[@]}" --tag "$release_tag" \
|
||||||
|
--output "type=docker,dest=$image_two,rewrite-timestamp=true" .
|
||||||
|
cmp --silent "$image_one" "$image_two"
|
||||||
|
docker load --input "$image_one"
|
||||||
|
candidate_id="$(docker image inspect --format '{{.Id}}' "$release_tag")"
|
||||||
|
buildkit_version="$(docker buildx inspect --bootstrap | sed -n 's/.*BuildKit version: *//p' | sed -n '1p')"
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--volume "$GITHUB_WORKSPACE:/source" \
|
||||||
|
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
|
||||||
|
--workdir /source \
|
||||||
|
"$RENDEZVOUS_RELEASE_BUILDER" \
|
||||||
|
python3 eng/release_artifacts.py record-container-build \
|
||||||
|
--provenance "$RENDEZVOUS_RELEASE_DIR/release-provenance.json" \
|
||||||
|
--buildx-version "$(docker buildx version)" \
|
||||||
|
--buildkit-version "$buildkit_version" \
|
||||||
|
--image-id "$candidate_id"
|
||||||
|
|
||||||
|
- name: Stage HTTP registration, browse, and authenticated UDP traversal
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
secret="deploy/compose/secrets/signing-key"
|
||||||
|
cleanup() {
|
||||||
|
RENDEZVOUS_UID=1654 RENDEZVOUS_GID=1654 RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}" \
|
||||||
|
docker compose -f deploy/compose/compose.yaml down --volumes >/dev/null 2>&1 || true
|
||||||
|
rm -f "$secret"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
install -d -m 0700 deploy/compose/secrets
|
||||||
|
openssl rand -out "$secret" 32
|
||||||
|
chmod 0444 "$secret"
|
||||||
|
export RENDEZVOUS_UID=1654
|
||||||
|
export RENDEZVOUS_GID=1654
|
||||||
|
export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
|
||||||
|
docker compose -f deploy/compose/compose.yaml up --detach --no-build
|
||||||
|
for attempt in {1..100}; do
|
||||||
|
curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1 && break
|
||||||
|
if (( attempt == 100 )); then
|
||||||
|
docker compose -f deploy/compose/compose.yaml logs rendezvous
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
./scripts/smoke-deployment.sh
|
||||||
|
|
||||||
|
- name: Scan candidate for high and critical vulnerabilities
|
||||||
|
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
|
||||||
|
with:
|
||||||
|
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
|
||||||
|
version: v0.69.3
|
||||||
|
format: table
|
||||||
|
exit-code: "1"
|
||||||
|
ignore-unfixed: false
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
|
||||||
|
- name: Generate container SPDX inventory
|
||||||
|
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
|
||||||
|
with:
|
||||||
|
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
|
||||||
|
version: v0.69.3
|
||||||
|
format: spdx-json
|
||||||
|
output: ${{ env.RENDEZVOUS_RELEASE_DIR }}/FinalFactory.Rendezvous.Container.${{ env.RENDEZVOUS_VERSION }}.spdx.json
|
||||||
|
|
||||||
|
- name: Finalize checksums over the publish-ready candidate
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source_date_epoch="$(git show -s --format=%ct HEAD)"
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--volume "$GITHUB_WORKSPACE:/source" \
|
||||||
|
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
|
||||||
|
--workdir /source \
|
||||||
|
"$RENDEZVOUS_RELEASE_BUILDER" \
|
||||||
|
bash -c 'python3 eng/release_artifacts.py normalize-container-sbom \
|
||||||
|
--file "$1/FinalFactory.Rendezvous.Container.$2.spdx.json" \
|
||||||
|
--version "$2" \
|
||||||
|
--commit "$3" \
|
||||||
|
--source-date-epoch "$4" \
|
||||||
|
&& ./scripts/finalize-release-candidate.sh "$2" "$1"' \
|
||||||
|
_ "$RENDEZVOUS_RELEASE_DIR" "$RENDEZVOUS_VERSION" "$GITHUB_SHA" "$source_date_epoch"
|
||||||
|
|
||||||
|
- name: Preserve verified candidate artifacts
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: rendezvous-${{ env.RENDEZVOUS_VERSION }}
|
||||||
|
path: ${{ env.RENDEZVOUS_RELEASE_DIR }}
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Install pinned signing client
|
||||||
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
with:
|
||||||
|
cosign-release: v3.0.6
|
||||||
|
|
||||||
|
- name: Publish once, sign, attest, and create release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RENDEZVOUS_RELEASE_USERNAME: ${{ secrets.RELEASE_USERNAME }}
|
||||||
|
RENDEZVOUS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||||
|
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||||
|
run: ./scripts/publish-release.sh "$RENDEZVOUS_VERSION" "$RENDEZVOUS_RELEASE_DIR"
|
||||||
@@ -6,3 +6,9 @@ TestResults/
|
|||||||
*.suo
|
*.suo
|
||||||
*.user
|
*.user
|
||||||
*.userosscache
|
*.userosscache
|
||||||
|
deploy/compose/.smoke.env
|
||||||
|
artifacts/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
deploy/compose/secrets/*
|
||||||
|
!deploy/compose/secrets/.gitignore
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build
|
||||||
|
ARG SOURCE_REVISION_ID
|
||||||
|
|
||||||
|
WORKDIR /source
|
||||||
|
COPY Directory.Build.props Directory.Packages.props NuGet.config global.json Rendezvous.slnx ./
|
||||||
|
COPY eng/Versions.props eng/Versions.props
|
||||||
|
COPY src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj src/FinalFactory.Rendezvous.Contracts/packages.lock.json src/FinalFactory.Rendezvous.Contracts/
|
||||||
|
COPY src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj src/FinalFactory.Rendezvous.Server/packages.lock.json src/FinalFactory.Rendezvous.Server/
|
||||||
|
RUN dotnet restore src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj --locked-mode
|
||||||
|
|
||||||
|
COPY src/FinalFactory.Rendezvous.Contracts/ src/FinalFactory.Rendezvous.Contracts/
|
||||||
|
COPY src/FinalFactory.Rendezvous.Server/ src/FinalFactory.Rendezvous.Server/
|
||||||
|
RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--no-restore \
|
||||||
|
--output /out \
|
||||||
|
/p:UseAppHost=false \
|
||||||
|
/p:OpenApiGenerateDocuments=false \
|
||||||
|
/p:ContinuousIntegrationBuild=true \
|
||||||
|
/p:RepositoryCommit="$SOURCE_REVISION_ID" \
|
||||||
|
/p:SourceRevisionId="$SOURCE_REVISION_ID"
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0.9-noble-chiseled@sha256:f820c4fbfb8bb204c3bbe05c69d48cd039cd0e67aa8f13ac1cec168819b90643 AS runtime
|
||||||
|
|
||||||
|
ENV ASPNETCORE_HTTP_PORTS=8080 \
|
||||||
|
DOTNET_EnableDiagnostics=0 \
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||||
|
TMPDIR=/tmp
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build --chown=1654:1654 /out/ ./
|
||||||
|
USER 1654:1654
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
EXPOSE 9050/udp
|
||||||
|
ENTRYPOINT ["dotnet", "FinalFactory.Rendezvous.Server.dll"]
|
||||||
@@ -38,7 +38,11 @@ UDP hole punching cannot guarantee a direct connection through every network. Sy
|
|||||||
- `FinalFactory.Rendezvous.TestClient` — thin interactive and scriptable host/browser/join diagnostic built only on the public SDK.
|
- `FinalFactory.Rendezvous.TestClient` — thin interactive and scriptable host/browser/join diagnostic built only on the public SDK.
|
||||||
- `FinalFactory.Rendezvous.Tests` — unit, integration, security, and connection-lifecycle tests.
|
- `FinalFactory.Rendezvous.Tests` — unit, integration, security, and connection-lifecycle tests.
|
||||||
|
|
||||||
The server directory and NAT mediator begin as separate modules in one deployable service because they share session, lease, authorization, and endpoint state. Their internal boundary should allow independent deployment later if scale, availability, or security requirements diverge.
|
The server directory and NAT mediator are separate modules in one single-active
|
||||||
|
deployable service because they share ephemeral session, lease, authorization,
|
||||||
|
replay, and endpoint state. Their internal boundary can support a future
|
||||||
|
explicitly designed shared-state architecture; operators must not create
|
||||||
|
multiple active v1 replicas.
|
||||||
|
|
||||||
## Service boundaries
|
## Service boundaries
|
||||||
|
|
||||||
@@ -77,9 +81,11 @@ The initial service does not provide:
|
|||||||
|
|
||||||
Rendezvous is under active roadmap development. The versioned contracts,
|
Rendezvous is under active roadmap development. The versioned contracts,
|
||||||
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
|
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
|
||||||
SDK coordination, typed connection outcomes, and thin public-SDK diagnostic client
|
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
|
||||||
are implemented. Deployment hardening, the broader NAT-topology harness, and
|
deterministic NAT topology harness, hostile-input controls,
|
||||||
the production-readiness roadmap remain in progress;
|
observability/operator surface, secure single-active Linux deployment, and
|
||||||
|
numeric capacity/resilience gates, and reproducible signed release pipeline are
|
||||||
|
implemented. Consumer pilots and final production-readiness gates remain in progress;
|
||||||
participating games must not treat the current repository as a finished production
|
participating games must not treat the current repository as a finished production
|
||||||
service until those gates land.
|
service until those gates land.
|
||||||
|
|
||||||
@@ -91,11 +97,32 @@ Tenant policy, publisher/operator principals, and production key custody are
|
|||||||
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
|
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
|
||||||
Layered HTTP/UDP budgets, overload behavior, and safe operational tuning are
|
Layered HTTP/UDP budgets, overload behavior, and safe operational tuning are
|
||||||
defined in [hostile-input and overload protection](docs/security/abuse-protection.md).
|
defined in [hostile-input and overload protection](docs/security/abuse-protection.md).
|
||||||
|
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
|
||||||
|
operator controls are defined in the
|
||||||
|
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
|
||||||
|
Concrete detect/contain/recover/verify procedures are in the
|
||||||
|
[incident and change runbooks](docs/operations/incident-runbooks.md).
|
||||||
|
The pinned non-root container, production topology, graceful drain, Linux
|
||||||
|
hardening, smoke procedure, and recovery lifecycle are documented in
|
||||||
|
[secure single-active Linux deployment](docs/deployment/linux.md).
|
||||||
|
The numeric core-state candidate profile, public launch objectives, accelerated
|
||||||
|
soak, resilience matrix, and single-active scaling decision are recorded in
|
||||||
|
[capacity and resilience gates](docs/operations/capacity-and-resilience.md).
|
||||||
|
Release versions, compatibility windows, immutable artifact construction,
|
||||||
|
signing, staged promotion, rollback, and migration are defined in
|
||||||
|
[releases and compatibility](docs/releases/README.md).
|
||||||
The scriptable host/browser/join diagnostic and its stable automation contract are
|
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).
|
||||||
|
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).
|
||||||
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -112,9 +139,11 @@ dotnet test Rendezvous.slnx --configuration Release --no-build
|
|||||||
Run the bootstrap server with
|
Run the bootstrap server with
|
||||||
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
|
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
|
||||||
the configured UDP mediator port; both stop through normal host cancellation.
|
the configured UDP mediator port; both stop through normal host cancellation.
|
||||||
The launch profile uses an ephemeral development-only signing key. Production
|
The launch profile uses separate ephemeral development-only publisher and operator
|
||||||
startup fails closed until externally supplied game policies and `env:` signing
|
signing keys. Production
|
||||||
key references resolve to valid key material; no reusable game secret is stored
|
startup fails closed until its advertised endpoints, proxy trust boundary,
|
||||||
|
externally supplied game policies, and `env:` (base64) or `file:` (raw,
|
||||||
|
absolute, non-symlink) signing-key references resolve safely; no reusable game secret is stored
|
||||||
in this repository or the public Client package.
|
in this repository or the public Client package.
|
||||||
The project dependency rules and supported runtime choices are documented in
|
The project dependency rules and supported runtime choices are documented in
|
||||||
[project and dependency boundaries](docs/architecture/project-boundaries.md).
|
[project and dependency boundaries](docs/architecture/project-boundaries.md).
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<Project Path="src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
|
<Project Path="src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/FinalFactory.Rendezvous.Capacity/FinalFactory.Rendezvous.Capacity.csproj" />
|
||||||
<Project Path="tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj" />
|
<Project Path="tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"AllowedHosts": "localhost;127.0.0.1;rendezvous",
|
||||||
|
"Rendezvous": {
|
||||||
|
"Deployment": {
|
||||||
|
"PublicHttpBaseUrl": "https://localhost/",
|
||||||
|
"PublicUdpHost": "127.0.0.1",
|
||||||
|
"PublicUdpPort": 9050,
|
||||||
|
"DrainDeadlineSeconds": 30,
|
||||||
|
"MinimumDrainSeconds": 1,
|
||||||
|
"SingleActiveInstance": true,
|
||||||
|
"AllowPrivatePublicEndpoints": true
|
||||||
|
},
|
||||||
|
"Udp": {
|
||||||
|
"ListenAddress": "0.0.0.0",
|
||||||
|
"Port": 9050
|
||||||
|
},
|
||||||
|
"AbuseProtection": {
|
||||||
|
"TrustedProxyAddresses": ["127.0.0.1"],
|
||||||
|
"OperatorAllowedAddresses": ["127.0.0.1"]
|
||||||
|
},
|
||||||
|
"Provisioning": {
|
||||||
|
"Issuer": "final-factory-rendezvous-smoke",
|
||||||
|
"Audience": "rendezvous-service",
|
||||||
|
"ClockSkewSeconds": 30,
|
||||||
|
"SigningKeys": [
|
||||||
|
{
|
||||||
|
"KeyId": "local-smoke-1",
|
||||||
|
"SecretReference": "file:/run/secrets/rendezvous-signing-key",
|
||||||
|
"CredentialKinds": ["DedicatedPublisher"],
|
||||||
|
"GameId": "space-game",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"NotBefore": "2026-01-01T00:00:00Z",
|
||||||
|
"SignUntil": "2100-01-01T00:00:00Z",
|
||||||
|
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"KeyId": "local-smoke-unscouted-1",
|
||||||
|
"SecretReference": "file:/run/secrets/rendezvous-signing-key",
|
||||||
|
"CredentialKinds": ["DedicatedPublisher"],
|
||||||
|
"GameId": "unscouted",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"NotBefore": "2026-01-01T00:00:00Z",
|
||||||
|
"SignUntil": "2100-01-01T00:00:00Z",
|
||||||
|
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Games": [
|
||||||
|
{
|
||||||
|
"GameId": "space-game",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"Enabled": true,
|
||||||
|
"ProtocolVersions": [1, 2],
|
||||||
|
"Regions": ["local"],
|
||||||
|
"VisibilityModes": ["Public"],
|
||||||
|
"PublisherTrustModes": ["ManagedDedicated"],
|
||||||
|
"MetadataValueMaxBytes": {
|
||||||
|
"mode": 32
|
||||||
|
},
|
||||||
|
"RequiredMetadataKeys": [],
|
||||||
|
"MetadataMaxBytes": 512,
|
||||||
|
"MetadataMaxKeys": 1,
|
||||||
|
"MaxListingsPerPrincipal": 10,
|
||||||
|
"MaxAnonymousListingsPerAddress": 0,
|
||||||
|
"MaxActiveJoinAttempts": 100,
|
||||||
|
"FallbackPolicy": "DedicatedEndpointAllowed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GameId": "unscouted",
|
||||||
|
"EnvironmentId": "smoke",
|
||||||
|
"Enabled": true,
|
||||||
|
"ProtocolVersions": [1],
|
||||||
|
"Regions": ["local"],
|
||||||
|
"VisibilityModes": ["Public"],
|
||||||
|
"PublisherTrustModes": ["ManagedDedicated"],
|
||||||
|
"MetadataValueMaxBytes": {
|
||||||
|
"mode": 32,
|
||||||
|
"world": 64,
|
||||||
|
"mods": 64
|
||||||
|
},
|
||||||
|
"RequiredMetadataKeys": ["mode", "world", "mods"],
|
||||||
|
"MetadataMaxBytes": 512,
|
||||||
|
"MetadataMaxKeys": 3,
|
||||||
|
"MaxListingsPerPrincipal": 10,
|
||||||
|
"MaxAnonymousListingsPerAddress": 0,
|
||||||
|
"MaxActiveJoinAttempts": 100,
|
||||||
|
"FallbackPolicy": "DedicatedEndpointAllowed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: rendezvous-local
|
||||||
|
|
||||||
|
services:
|
||||||
|
rendezvous:
|
||||||
|
image: "${RENDEZVOUS_IMAGE:-finalfactory/rendezvous:local}"
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
init: true
|
||||||
|
user: "${RENDEZVOUS_UID:?set RENDEZVOUS_UID to a non-root host UID}:${RENDEZVOUS_GID:?set RENDEZVOUS_GID to its GID}"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=16m,uid=${RENDEZVOUS_UID},gid=${RENDEZVOUS_GID},mode=0700
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
pids_limit: 128
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 4096
|
||||||
|
hard: 4096
|
||||||
|
stop_grace_period: 40s
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
|
ASPNETCORE_HTTP_PORTS: "8080"
|
||||||
|
volumes:
|
||||||
|
- ./appsettings.Production.json:/app/appsettings.Production.json:ro
|
||||||
|
- ./secrets/signing-key:/run/secrets/rendezvous-signing-key:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080/tcp"
|
||||||
|
- "9050:9050/udp"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Final Factory Rendezvous service
|
||||||
|
Documentation=https://git.finalfactory.de/HeiKyu/Rendezvous
|
||||||
|
After=network-online.target time-sync.target
|
||||||
|
Wants=network-online.target time-sync.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=rendezvous
|
||||||
|
Group=rendezvous
|
||||||
|
WorkingDirectory=/opt/rendezvous
|
||||||
|
ExecStart=/usr/bin/dotnet /opt/rendezvous/FinalFactory.Rendezvous.Server.dll
|
||||||
|
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
Environment=ASPNETCORE_HTTP_PORTS=8080
|
||||||
|
Environment=DOTNET_EnableDiagnostics=0
|
||||||
|
EnvironmentFile=-/etc/rendezvous/rendezvous.env
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=40s
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateDevices=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
LockPersonality=true
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
UMask=0077
|
||||||
|
LimitNOFILE=4096
|
||||||
|
CPUQuota=200%
|
||||||
|
MemoryMax=2G
|
||||||
|
TasksMax=128
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+1676
-4
File diff suppressed because it is too large
Load Diff
@@ -129,9 +129,18 @@ until the owner records:
|
|||||||
- which games may enable anonymous unlisted player hosting;
|
- which games may enable anonymous unlisted player hosting;
|
||||||
- deployment regions, data-processing jurisdiction, and approval of the stated
|
- deployment regions, data-processing jurisdiction, and approval of the stated
|
||||||
30-day audit/13-month aggregate retention periods;
|
30-day audit/13-month aggregate retention periods;
|
||||||
- the per-game dedicated fallback endpoint policy;
|
- the per-game dedicated fallback endpoint policy.
|
||||||
- the measured supported profile and whether the 99.5% single-active objective
|
|
||||||
is sufficient or shared-state/high-availability work must be brought forward.
|
Issue #18 measured and ratified the original 2-vCPU/2-GiB, 25,000-listing,
|
||||||
|
10,000-attempt core-state candidate profile and retained the 99.5% single-active
|
||||||
|
topology. It does not claim that core measurements prove public HTTP/UDP SLOs.
|
||||||
|
The versioned evidence, RTO, failure domains, and explicit signals that trigger
|
||||||
|
shared-state/high-availability work are recorded in the
|
||||||
|
[capacity and resilience gate](../operations/capacity-and-resilience.md). The
|
||||||
|
real-network canary in #23 must confirm that the proposed regional launch load
|
||||||
|
fits this profile and validate the public SLOs; it may lower the launch cap but
|
||||||
|
may not silently enable a
|
||||||
|
second active instance.
|
||||||
|
|
||||||
These are configuration and launch decisions, not permission to weaken the
|
These are configuration and launch decisions, not permission to weaken the
|
||||||
tenant, replay, endpoint-verification, or secret-handling controls.
|
tenant, replay, endpoint-verification, or secret-handling controls.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# Secure single-active Linux deployment
|
||||||
|
|
||||||
|
Tracking: #17
|
||||||
|
|
||||||
|
Rendezvous v1 stores listings, observed endpoints, join attempts, replay markers,
|
||||||
|
and runtime revocations only in the process that accepted them. Deploy exactly
|
||||||
|
one active instance. A second live replica would have a different directory and
|
||||||
|
replay boundary; `SingleActiveInstance=false` is therefore rejected rather than
|
||||||
|
presented as high availability.
|
||||||
|
|
||||||
|
## Pinned container
|
||||||
|
|
||||||
|
The root `Dockerfile` uses a multi-stage .NET 10 build and pins both Microsoft
|
||||||
|
base images by multi-architecture manifest digest. The runtime is the chiseled
|
||||||
|
ASP.NET image, contains only the published server, runs as UID/GID 1654, exposes
|
||||||
|
TCP 8080 and UDP 9050 explicitly, and does not require a writable application
|
||||||
|
directory. Supply a small writable `/tmp` tmpfs because runtime libraries can
|
||||||
|
legitimately need temporary space; keep the root filesystem read-only.
|
||||||
|
|
||||||
|
From a clean checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --pull=false --tag finalfactory/rendezvous:local .
|
||||||
|
docker inspect --format '{{.Config.User}}' finalfactory/rendezvous:local
|
||||||
|
```
|
||||||
|
|
||||||
|
The reported user must be `1654:1654`. Digest pins make a rebuild reproducible;
|
||||||
|
updating .NET is an explicit reviewed change to the tag, digest, SDK pin, and
|
||||||
|
lock files together. Do not replace the digest with `latest` in production.
|
||||||
|
|
||||||
|
The local Compose example applies a read-only root, non-root user, no Linux
|
||||||
|
capabilities, `no-new-privileges`, bounded PIDs/files/memory/CPU, and a shutdown
|
||||||
|
grace period longer than the service drain deadline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -d -m 0700 deploy/compose/secrets
|
||||||
|
umask 077
|
||||||
|
openssl rand -out deploy/compose/secrets/signing-key 32
|
||||||
|
export RENDEZVOUS_UID="$(id -u)"
|
||||||
|
export RENDEZVOUS_GID="$(id -g)"
|
||||||
|
test "$RENDEZVOUS_UID" -ne 0
|
||||||
|
docker compose -f deploy/compose/compose.yaml up --build --detach
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy/compose/appsettings.Production.json` is a local/private-bridge smoke
|
||||||
|
profile, not an Internet template: TCP is published only on host loopback, the
|
||||||
|
explicit `rendezvous` host name serves isolated clients on the Compose network,
|
||||||
|
and the profile deliberately opts into private advertised endpoints without a
|
||||||
|
TLS proxy. Its random key is ignored by Git and must be
|
||||||
|
deleted after use. Its deliberately long key window only keeps this disposable
|
||||||
|
local fixture usable; production keys require short, reviewed rotation windows.
|
||||||
|
Production configuration must use its real public names and must leave
|
||||||
|
`AllowPrivatePublicEndpoints` false.
|
||||||
|
|
||||||
|
## Production topology
|
||||||
|
|
||||||
|
Use one active service behind a source-preserving edge:
|
||||||
|
|
||||||
|
```text
|
||||||
|
clients -- HTTPS/443 --> TLS reverse proxy -- HTTP/8080 --> Rendezvous
|
||||||
|
clients -- UDP/9050 -------------------------------------> Rendezvous
|
||||||
|
```
|
||||||
|
|
||||||
|
- Give the HTTPS origin and UDP endpoint stable DNS names. Set
|
||||||
|
`PublicHttpBaseUrl` to the exact external HTTPS origin and `PublicUdpHost` /
|
||||||
|
`PublicUdpPort` to the endpoint given to game clients.
|
||||||
|
- Terminate TLS 1.2 or newer at a maintained reverse proxy. Bind internal HTTP
|
||||||
|
only to the private proxy network. Restrict `AllowedHosts` to the public HTTP
|
||||||
|
host; wildcard host filtering is rejected.
|
||||||
|
- Put only the proxy's exact literal addresses in
|
||||||
|
`Rendezvous:AbuseProtection:TrustedProxyAddresses`. Rendezvous ignores
|
||||||
|
forwarded headers from every other source. Keep the last proxy from replacing
|
||||||
|
the original client address and prevent direct access to TCP 8080.
|
||||||
|
- Forward UDP as UDP, without an HTTP proxy. NAT, load balancer, firewall, and
|
||||||
|
return routing must preserve the client's source IP/port and must send replies
|
||||||
|
from the same advertised IP/port. Many HTTP load balancers, Kubernetes ingress
|
||||||
|
controllers, rootless container port proxies, anycast products, and generic
|
||||||
|
L7 services cannot guarantee this. Do not deploy through one unless an actual
|
||||||
|
host/join smoke proves both observed source and reply path. A load balancer
|
||||||
|
must have exactly one healthy Rendezvous target.
|
||||||
|
- Permit inbound TCP 443 to the TLS proxy and UDP 9050 to Rendezvous. Permit the
|
||||||
|
proxy to reach TCP 8080. Permit DNS, time synchronization, image/telemetry
|
||||||
|
destinations as required by local policy, and UDP replies to client endpoints.
|
||||||
|
Deny public TCP 8080 and every unused inbound port.
|
||||||
|
|
||||||
|
Readiness is the load-balancer gate; liveness is only a process-health signal.
|
||||||
|
Remove a draining instance from new traffic when `/health/ready` becomes 503.
|
||||||
|
Do not use liveness failure to start a second active process while the old one
|
||||||
|
still owns the public UDP address.
|
||||||
|
|
||||||
|
## Required production configuration
|
||||||
|
|
||||||
|
Production startup validates all of these before binding listeners:
|
||||||
|
|
||||||
|
- an absolute path-free HTTPS `PublicHttpBaseUrl`;
|
||||||
|
- an unambiguous public `PublicUdpHost` and port;
|
||||||
|
- `SingleActiveInstance=true`, an explicit non-wildcard `AllowedHosts`, and at
|
||||||
|
least one exact trusted TLS-proxy address;
|
||||||
|
- a 1-30 second drain deadline whose minimum observation interval is shorter;
|
||||||
|
- at least one enabled game policy and an active scoped signing key.
|
||||||
|
|
||||||
|
Missing values produce an actionable startup error. The checked-in base file is
|
||||||
|
intentionally unsafe for Production so an accidental bare launch fails closed.
|
||||||
|
|
||||||
|
Signing keys support two external references:
|
||||||
|
|
||||||
|
- `env:NAME` reads 1-4096 bytes encoded as base64 from `NAME`;
|
||||||
|
- `file:/absolute/path` reads 1-4096 raw bytes from a non-symlink file.
|
||||||
|
|
||||||
|
Prefer a read-only container secret owned by the configured container identity.
|
||||||
|
For systemd, use a root-owned, `rendezvous`-group-owned `0440` file (or an
|
||||||
|
equivalent narrow ACL) so the non-root process can read but not replace it. A
|
||||||
|
signing key must contain at least 32 random bytes. Never put the key, publisher/operator
|
||||||
|
credential, or secret value in JSON, a command argument, an image layer, Compose
|
||||||
|
environment, logs, metrics, or source control. Configuration contains only the
|
||||||
|
reference and non-secret lifecycle metadata. A vault/KMS adapter can replace the
|
||||||
|
provider where local policy requires it.
|
||||||
|
|
||||||
|
Keep the host clock synchronized with authenticated NTP. Credential and key
|
||||||
|
windows use wall time; lease, timeout, drain, and rate-limit deadlines use a
|
||||||
|
monotonic clock. Alert on clock synchronization loss before rotating keys.
|
||||||
|
|
||||||
|
The checked-in Compose limits (one CPU and 512 MiB) are for its isolated smoke
|
||||||
|
profile, not a production capacity claim. The measured core-state candidate
|
||||||
|
uses 2 vCPU and 2 GiB with the same 128-PID/4096-descriptor ceilings; see
|
||||||
|
the [capacity and resilience gate](../operations/capacity-and-resilience.md).
|
||||||
|
Measure real traffic, then change resource limits and server budgets together.
|
||||||
|
Memory pressure or CPU throttling must not extend orchestrator termination past
|
||||||
|
`DrainDeadlineSeconds` plus five seconds.
|
||||||
|
|
||||||
|
## Graceful shutdown
|
||||||
|
|
||||||
|
SIGTERM and the authenticated operator drain both stop new registrations and
|
||||||
|
join attempts immediately. On process shutdown, HTTP and UDP remain available
|
||||||
|
long enough for existing join attempts to finish. The service exits as soon as
|
||||||
|
the minimum drain interval has elapsed and no attempts remain, or forcibly
|
||||||
|
clears all ephemeral state at the configured deadline. It then stops UDP and
|
||||||
|
HTTP listeners and exits. Configure Docker/systemd/Kubernetes termination grace
|
||||||
|
strictly longer than the service deadline; the examples use 40 seconds for a
|
||||||
|
30-second drain.
|
||||||
|
|
||||||
|
Never use SIGKILL for a normal rollout. After stopping, verify the process is
|
||||||
|
gone and neither `8080/tcp` nor `9050/udp` is bound before starting its
|
||||||
|
replacement on the same host. A crashed or force-killed process cannot drain;
|
||||||
|
clients recover through bounded retries and hosts re-register.
|
||||||
|
|
||||||
|
## systemd alternative
|
||||||
|
|
||||||
|
Publish the server for Linux, install the immutable output at `/opt/rendezvous`,
|
||||||
|
place production configuration beside the application read-only, place key
|
||||||
|
files below `/etc/rendezvous`, and install `deploy/systemd/rendezvous.service`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet publish src/FinalFactory.Rendezvous.Server \
|
||||||
|
--configuration Release --runtime linux-x64 --self-contained false \
|
||||||
|
--output publish/rendezvous
|
||||||
|
systemd-analyze verify deploy/systemd/rendezvous.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now rendezvous.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the dedicated `rendezvous` user without a login shell. Keep
|
||||||
|
`/opt/rendezvous` and `/etc/rendezvous` root-owned and non-writable by that user;
|
||||||
|
install each required key with `root:rendezvous` ownership and mode `0440`. The
|
||||||
|
unit applies the measured 2-vCPU/2-GiB core-state candidate profile plus the
|
||||||
|
same filesystem, privilege, network-family, and shutdown hardening as Compose.
|
||||||
|
|
||||||
|
## HTTP and UDP smoke
|
||||||
|
|
||||||
|
Build the diagnostic once, then exercise the actual published HTTP and UDP
|
||||||
|
paths. The test creates a public listing, sends authenticated presence and punch
|
||||||
|
traffic through UDP 9050, establishes peer-to-peer traffic, reports the outcome,
|
||||||
|
and deregisters cleanly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
||||||
|
./scripts/smoke-deployment.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For the local Compose profile, the script derives a ten-minute diagnostic
|
||||||
|
publisher credential from the ignored local key without printing either secret.
|
||||||
|
The fixed-scope helper used by the smoke can also support the manual
|
||||||
|
[TestClient local flow](../integration/test-client.md); it is deliberately not a
|
||||||
|
production issuer.
|
||||||
|
For production, do not copy the signing key to the smoke host. Instead inject a
|
||||||
|
short-lived, region-scoped credential through
|
||||||
|
`RENDEZVOUS_PUBLISHER_CREDENTIAL`, and set the external endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<short-lived deployment credential>'
|
||||||
|
export RENDEZVOUS_SMOKE_HTTP_URL='https://rendezvous.your-company.tld/'
|
||||||
|
export RENDEZVOUS_SMOKE_UDP_ENDPOINT='rendezvous-udp.your-company.tld:9050'
|
||||||
|
export RENDEZVOUS_SMOKE_GAME_ID='<credential game ID>'
|
||||||
|
export RENDEZVOUS_SMOKE_ENVIRONMENT_ID='<credential environment ID>'
|
||||||
|
export RENDEZVOUS_SMOKE_REGION='<credential region>'
|
||||||
|
export RENDEZVOUS_SMOKE_PROTOCOL_VERSION='<enabled protocol version>'
|
||||||
|
./scripts/smoke-deployment.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Those four scope values must match both the short-lived credential and an
|
||||||
|
enabled server policy. The defaults (`space-game`, `smoke`, `local`, protocol
|
||||||
|
`1`) are only for the checked-in local Compose profile.
|
||||||
|
|
||||||
|
The smoke fails unless both health endpoints and the complete authenticated UDP
|
||||||
|
mediation/direct-traffic flow succeed. It does not prove every consumer NAT;
|
||||||
|
run the topology harness and representative external-network tests as well.
|
||||||
|
|
||||||
|
## Restart, upgrade, rollback, and backup
|
||||||
|
|
||||||
|
Rendezvous has no durable runtime database. Restarting intentionally loses all
|
||||||
|
listings, observed endpoints, attempts, replay markers, and runtime-only
|
||||||
|
revocations. Hosts must treat registration as a renewable lease and re-register
|
||||||
|
after service recovery. Clients must re-browse and start a new bounded attempt.
|
||||||
|
|
||||||
|
Back up only reviewed configuration, policy, secret references, key material and
|
||||||
|
its custody/lifecycle records, deployment manifests, and image digest. Never
|
||||||
|
claim a backup contains live sessions or endpoints. Restore keys only through the
|
||||||
|
secret system, not into the image or repository.
|
||||||
|
|
||||||
|
For an upgrade:
|
||||||
|
|
||||||
|
1. Build and test the new pinned digest; validate configuration without starting
|
||||||
|
a second active instance.
|
||||||
|
2. Drain and stop the current process, verify both sockets are released, then
|
||||||
|
start the replacement on the same public endpoints.
|
||||||
|
3. Require live/readiness and HTTP+UDP smoke success; monitor host
|
||||||
|
re-registration, error rate, and direct-connect outcomes.
|
||||||
|
|
||||||
|
For rollback, repeat the same stop-before-start sequence with the previously
|
||||||
|
recorded image digest and compatible configuration/key set. Never run old and
|
||||||
|
new versions concurrently to avoid split ephemeral state. If a wire-incompatible
|
||||||
|
change ever becomes necessary, use a new API/protocol version rather than a
|
||||||
|
rolling two-version replica set.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"evidenceVersion": "v2",
|
||||||
|
"generatedAt": "2026-07-16T14:10:53.6981858+00:00",
|
||||||
|
"profile": "candidate",
|
||||||
|
"runtime": {
|
||||||
|
"framework": ".NET 10.0.9",
|
||||||
|
"operatingSystem": "CachyOS",
|
||||||
|
"kernel": "Unix 7.1.3.2",
|
||||||
|
"architecture": "X64",
|
||||||
|
"cpuModel": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||||
|
"processorCount": 2,
|
||||||
|
"cpuAffinity": "0,1",
|
||||||
|
"cpuQuota": "not-enforced",
|
||||||
|
"memoryLimit": "not-enforced",
|
||||||
|
"garbageCollector": "workstation",
|
||||||
|
"commitSha": "cf14836d48b0b4aaa67f99433f4fba3585bcd2bb",
|
||||||
|
"treeState": "clean",
|
||||||
|
"command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh",
|
||||||
|
"imageDigest": "not-containerized",
|
||||||
|
"workloadSeed": "fixed-sequences-random-identifiers",
|
||||||
|
"capacityPhaseAverageCpuPercent": 56.37724115383554,
|
||||||
|
"peakWorkingSetBytes": 169705472,
|
||||||
|
"managedBytesAfterCleanup": 35615200
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"visibleListings": 25000,
|
||||||
|
"activeJoinAttempts": 10000,
|
||||||
|
"coreControlOperationsPerSecond": 200,
|
||||||
|
"coreMediationOperationsPerSecond": 2000,
|
||||||
|
"coreControlP95Milliseconds": 200,
|
||||||
|
"coreMediationP95Milliseconds": 100,
|
||||||
|
"maximumAverageCpuPercent": 70,
|
||||||
|
"maximumWorkingSetBytes": 1610612736,
|
||||||
|
"soakCycles": 1000,
|
||||||
|
"soakDurationSeconds": 300
|
||||||
|
},
|
||||||
|
"measurements": [
|
||||||
|
{
|
||||||
|
"operation": "registration-and-presence",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.003,
|
||||||
|
"p95Milliseconds": 0.0046,
|
||||||
|
"p99Milliseconds": 0.0054,
|
||||||
|
"operationsPerSecond": 282453.96000451926,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "lease-renewal",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0004,
|
||||||
|
"p95Milliseconds": 0.0009,
|
||||||
|
"p99Milliseconds": 0.0021,
|
||||||
|
"operationsPerSecond": 968992.2480620155,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "visible-session-browse",
|
||||||
|
"samples": 250,
|
||||||
|
"p50Milliseconds": 0.9046,
|
||||||
|
"p95Milliseconds": 3.3704,
|
||||||
|
"p99Milliseconds": 3.9471,
|
||||||
|
"operationsPerSecond": 695.5799787597697,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "join-attempt-issuance",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0029,
|
||||||
|
"p95Milliseconds": 0.0045,
|
||||||
|
"p99Milliseconds": 0.0055,
|
||||||
|
"operationsPerSecond": 296428.042092782,
|
||||||
|
"minimumOperationsPerSecond": 200,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "simultaneous-punch-pairing",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0043,
|
||||||
|
"p95Milliseconds": 0.0073,
|
||||||
|
"p99Milliseconds": 0.0115,
|
||||||
|
"operationsPerSecond": 109212.03516627532,
|
||||||
|
"minimumOperationsPerSecond": 2000,
|
||||||
|
"budgetMilliseconds": 100,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "principal-revocation",
|
||||||
|
"samples": 50,
|
||||||
|
"p50Milliseconds": 0.518,
|
||||||
|
"p95Milliseconds": 0.7049,
|
||||||
|
"p99Milliseconds": 11.8557,
|
||||||
|
"operationsPerSecond": 1320.1773262184577,
|
||||||
|
"minimumOperationsPerSecond": 50,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "telemetry-recording",
|
||||||
|
"samples": 1000,
|
||||||
|
"p50Milliseconds": 0.0001,
|
||||||
|
"p95Milliseconds": 0.0001,
|
||||||
|
"p99Milliseconds": 0.0001,
|
||||||
|
"operationsPerSecond": 1438641.9220256077,
|
||||||
|
"minimumOperationsPerSecond": 10000,
|
||||||
|
"budgetMilliseconds": 1,
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "coincident-listing-attempt-expiry",
|
||||||
|
"samples": 1,
|
||||||
|
"p50Milliseconds": 29.6882,
|
||||||
|
"p95Milliseconds": 29.6882,
|
||||||
|
"p99Milliseconds": 29.6882,
|
||||||
|
"operationsPerSecond": 33.682962483916384,
|
||||||
|
"minimumOperationsPerSecond": 0,
|
||||||
|
"budgetMilliseconds": 200,
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": {
|
||||||
|
"peakListings": 25000,
|
||||||
|
"peakAttempts": 10000,
|
||||||
|
"peakReplayMarkers": 0,
|
||||||
|
"finalListings": 0,
|
||||||
|
"finalAttempts": 0,
|
||||||
|
"finalReplayMarkers": 0,
|
||||||
|
"expiryChurn": 94906,
|
||||||
|
"maintenanceSweeps": 36307,
|
||||||
|
"soakCyclesCompleted": 75126848,
|
||||||
|
"soakDurationSeconds": 300.0000015,
|
||||||
|
"soakPeakScheduledExpiryEntries": 7,
|
||||||
|
"soakManagedGrowthBytes": -257288,
|
||||||
|
"soakHandleGrowth": 2,
|
||||||
|
"restartStartedEmpty": true,
|
||||||
|
"overloadWasTyped": true,
|
||||||
|
"recoverySucceeded": true
|
||||||
|
},
|
||||||
|
"failures": [],
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+193
-68
@@ -1,97 +1,222 @@
|
|||||||
# 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
|
The deployment smoke performs the full health, publish, join, mediation, direct
|
||||||
|
traffic, outcome-report, and cleanup flow using bounded waits:
|
||||||
|
|
||||||
The deterministic loopback test proves the complete service/host/client protocol,
|
```bash
|
||||||
ticket admission, and peer-to-peer payload path. Loopback is not evidence that all
|
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
||||||
consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths,
|
./scripts/smoke-deployment.sh
|
||||||
or platform policies permit hole punching. Same-LAN, separated observed endpoints,
|
```
|
||||||
network namespaces/containers, mediator restart, and adverse topology coverage
|
|
||||||
belong to the topology harness tracked by #14. Production rollout still requires
|
For custom automation, capture JSON and preserve the process status separately:
|
||||||
tests from representative networks and a game-owned fallback policy.
|
|
||||||
|
```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`,
|
||||||
|
and `join.outcome-report`.
|
||||||
|
|
||||||
|
| 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Capacity, resilience, and availability gate
|
||||||
|
|
||||||
|
Tracking: #18
|
||||||
|
|
||||||
|
This gate turns the v1 budgets in ADR 0003 into a repeatable release decision.
|
||||||
|
It does not turn Rendezvous into a horizontally scalable service: v1 remains one
|
||||||
|
active process with bounded in-memory state. A second process may be a cold
|
||||||
|
standby, but it must not accept traffic until the first process has stopped and
|
||||||
|
released the public HTTP and UDP endpoints.
|
||||||
|
|
||||||
|
## Launch envelope and approved core-state profile
|
||||||
|
|
||||||
|
The approved core-state profile is one Linux process limited to 2 vCPU and
|
||||||
|
2 GiB RAM. Public HTTP/UDP numbers are launch objectives that require the #23
|
||||||
|
real-network canary before they become a supported service claim:
|
||||||
|
|
||||||
|
| Dimension | Value | Evidence status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Visible listings | 25,000 | Enforced and measured here |
|
||||||
|
| Active join attempts | 10,000 | Enforced and measured here |
|
||||||
|
| Core control path | 200 operations/second; p95 at most 200 ms | Measured here |
|
||||||
|
| Core mediation path | 2,000 pairings/second; p95 at most 100 ms | Measured here |
|
||||||
|
| Sustained HTTP demand | 200 requests/second | #23 launch objective; not yet a supported claim |
|
||||||
|
| Sustained UDP demand | 2,000 datagrams/second | #23 launch objective; not yet a supported claim |
|
||||||
|
| Public HTTP/UDP latency | p95 at most 200 ms / 100 ms | #23 launch objective; not yet a supported claim |
|
||||||
|
| Capacity-phase average CPU / peak working memory | below 70% / below 1.5 GiB | Measured for the core candidate |
|
||||||
|
| Valid in-profile monthly availability | 99.5%, excluding announced maintenance | Operational objective |
|
||||||
|
| Process-ready RTO / host-visible recovery | 15 seconds / 90 seconds | 15 seconds automated; 90-second deployment drill required |
|
||||||
|
|
||||||
|
The proposed public-network mix is 20% registration/update, 30% lease-critical
|
||||||
|
renew/delete, 30% browse, and 20% join authorization for HTTP. The UDP mix is
|
||||||
|
60% authenticated host-presence refresh, 30% attempt contributions, and 10%
|
||||||
|
invalid or duplicate traffic that must be dropped early. A deployment may use a
|
||||||
|
lower per-game profile, but must not claim a higher one without new versioned
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
The capacity harness fills the complete state ceilings, then measures
|
||||||
|
registration plus presence, renewal, a 100-item compatible browse, join
|
||||||
|
issuance, simultaneous two-peer pairing, principal revocation, and telemetry.
|
||||||
|
It applies 200/100 ms guardrails and minimum 200 control / 2,000 mediation
|
||||||
|
operations per second to the core hot path. Those measurements deliberately
|
||||||
|
exclude Kestrel, LiteNetLib, TLS, JSON, socket scheduling, and the documented
|
||||||
|
mixed traffic shape. The #23 real-network canary must exercise those layers,
|
||||||
|
rate-shape the mix, record errors and shedding, and meet the public objectives
|
||||||
|
before launch; a core result is not a public-network latency or throughput claim.
|
||||||
|
|
||||||
|
## Reproduce the evidence
|
||||||
|
|
||||||
|
Every push runs the quick profile and the selected fault matrix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-capacity-gate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the production candidate on an otherwise idle Linux host and restrict the
|
||||||
|
runtime to two logical CPUs. The default candidate includes a five-minute,
|
||||||
|
high-intensity expiry soak; use 3,600 seconds for a release-candidate endurance
|
||||||
|
run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RENDEZVOUS_CAPACITY_PROFILE=candidate
|
||||||
|
export RENDEZVOUS_CAPACITY_CPUSET=0,1
|
||||||
|
export RENDEZVOUS_CAPACITY_OUTPUT="$PWD/artifacts/capacity/candidate.json"
|
||||||
|
./scripts/run-capacity-gate.sh
|
||||||
|
|
||||||
|
# Release-candidate endurance override:
|
||||||
|
dotnet run --project tests/FinalFactory.Rendezvous.Capacity \
|
||||||
|
--configuration Release --no-build -- \
|
||||||
|
--profile candidate --soak-seconds 3600 \
|
||||||
|
--output artifacts/capacity/candidate-endurance.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The machine must have at least 2 GiB available to the process. For formal
|
||||||
|
deployment evidence, run inside the same cgroup/container shape as production.
|
||||||
|
The v2 JSON embeds the commit and tree state, command, image context, CPU model,
|
||||||
|
kernel, affinity, cgroup quota/limit, collector mode, and workload seed. Supply
|
||||||
|
`RENDEZVOUS_EVIDENCE_IMAGE_DIGEST` when running a release image. Do not compare
|
||||||
|
results collected under a debugger,
|
||||||
|
concurrent build, thermal throttling, or oversubscribed CI host.
|
||||||
|
|
||||||
|
The checked-in baseline is
|
||||||
|
[`candidate-2cpu.json`](../evidence/capacity/v2/candidate-2cpu.json). It was
|
||||||
|
produced on .NET 10.0.9/Linux x64 with CPU affinity restricted to two logical
|
||||||
|
CPUs. It filled 25,000 listings and 10,000 attempts, peaked at about 162 MiB,
|
||||||
|
and cleared all active/retained state. The five-minute baseline supersedes any
|
||||||
|
earlier local probe when its timestamp and target duration differ.
|
||||||
|
|
||||||
|
## Soak and bounded-state interpretation
|
||||||
|
|
||||||
|
Each soak cycle creates a listing, repeatedly renews its lease and refreshes
|
||||||
|
presence, creates a join attempt, replay marker, and retained outcome, checks
|
||||||
|
that scheduled expiry entries remain proportional to live keys, then advances
|
||||||
|
the injected monotonic clock beyond all
|
||||||
|
deadlines, and verifies that listings, attempts, replay, idempotency, and outcome
|
||||||
|
state return to zero. The candidate also measures managed-memory and process
|
||||||
|
handle deltas after full collection. Failure is any retained state, more than
|
||||||
|
64 MiB retained managed memory, more than eight retained handles, a working set
|
||||||
|
above 1.5 GiB, an untyped capacity result, or failure to admit work after expiry.
|
||||||
|
|
||||||
|
This accelerated soak intentionally executes far more state lifecycle/cleanup
|
||||||
|
events than wall-clock traffic would permit. It catches stale deadline-queue
|
||||||
|
entries, cache growth, replay/idempotency retention, and cleanup cost. Because
|
||||||
|
it does not open Kestrel/LiteNetLib connections, its process-handle delta is only
|
||||||
|
a harness guard and is not evidence of transport stability by itself. The
|
||||||
|
selected production-process gate adds a ten-second real HTTP/UDP transport soak,
|
||||||
|
samples child-process handles and RSS, asserts bounded growth, then verifies a
|
||||||
|
clean SIGTERM and socket release. #23 must extend that into the full rate-shaped
|
||||||
|
multi-client canary while sampling queues, managed memory, and state
|
||||||
|
cardinalities. A one-hour core override remains required before tagging a
|
||||||
|
production release.
|
||||||
|
|
||||||
|
## Fault and recovery matrix
|
||||||
|
|
||||||
|
`run-capacity-gate.sh` runs these deterministic production paths before the
|
||||||
|
numeric profile:
|
||||||
|
|
||||||
|
| Fault | Required result |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTP/UDP overload and tracker exhaustion | Typed HTTP `429`/`CapacityExceeded`, silent UDP drop, bounded tracker keys, recovery after the window |
|
||||||
|
| Optional traffic saturation | Lease-critical renew/update/delete capacity remains available |
|
||||||
|
| Store/dependency unavailable | Readiness fails; new authorization returns typed `ServiceUnavailable`; liveness remains independent |
|
||||||
|
| Graceful drain/SIGTERM | New work returns `Draining`; existing pairing may finish; process exits 0 and releases TCP/UDP before the deadline |
|
||||||
|
| Hard restart | In-flight state is lost; SDK reports typed `ServiceUnavailable`; a host re-registers, rebinds presence, and becomes the only browser-visible replacement |
|
||||||
|
| UDP listener bind/restart | Readiness stays false without the required listener; rebinding the advertised port restores native LiteNetLib pairing |
|
||||||
|
| Wall-clock jump/skew | Monotonic lease/attempt authority is neither shortened nor extended; credential skew remains capped at 30 seconds |
|
||||||
|
| Signing-secret rotation | New key signs, overlap verifies, retired/revoked key rejects, missing material fails startup |
|
||||||
|
| Principal revocation | Listing, presence, attempts, and outcome paths are removed atomically within the latency budget |
|
||||||
|
|
||||||
|
No external database exists in v1, so “dependency/store failure” means the
|
||||||
|
process-local atomic store is marked unavailable or a required listener/key is
|
||||||
|
unready. The service fails closed rather than pretending a degraded writable
|
||||||
|
mode exists.
|
||||||
|
|
||||||
|
## Bandwidth and amplification
|
||||||
|
|
||||||
|
- Accepted application datagrams are at most 1,200 bytes.
|
||||||
|
- Malformed, oversized, unauthenticated, stale, replayed, wrong-role, and
|
||||||
|
rate-limited traffic receives zero response bytes.
|
||||||
|
- A completing authenticated contribution produces at most one introduction to
|
||||||
|
each observed peer, and the combined response is at most 2.0 times that
|
||||||
|
contribution's bytes.
|
||||||
|
- The frozen-envelope and native LiteNetLib socket tests measure this on the real
|
||||||
|
UDP listener; the hostile corpus and allocation gate exercise 10,000+ inputs
|
||||||
|
without input-sized logs, tasks, or queues.
|
||||||
|
|
||||||
|
Bandwidth planning must therefore reserve ingress for the configured 2,000
|
||||||
|
datagrams/second plus edge overhead and egress for a worst-case verified 2.0
|
||||||
|
amplification. Actual successful pairs normally use two contributions and two
|
||||||
|
introductions; normal gameplay leaves Rendezvous entirely.
|
||||||
|
|
||||||
|
## Availability decision
|
||||||
|
|
||||||
|
Single-active remains the v1 topology. The measured core profile proves bounded
|
||||||
|
state and substantial core-path headroom, while public launch capacity remains
|
||||||
|
conditional on #23. The service has a bounded stop-before-start restart path.
|
||||||
|
Its failure domain is deliberately
|
||||||
|
one process/node/public UDP endpoint: node, kernel, host network, DNS/TLS edge,
|
||||||
|
secret configuration, or operator error can remove all readiness until the cold
|
||||||
|
replacement owns the same source-preserving endpoint.
|
||||||
|
|
||||||
|
The 99.5% objective permits about 216 minutes of unannounced downtime in a
|
||||||
|
30-day month. Operations must target process readiness within 15 seconds and
|
||||||
|
host-visible re-registration within 90 seconds, page when no ready instance
|
||||||
|
exists, and include detection plus recovery in the monthly budget. The current
|
||||||
|
in-process test validates typed downtime, same-port HTTP restart, fresh
|
||||||
|
registration, presence rebinding, and browser visibility in under five seconds;
|
||||||
|
the production-process test separately validates graceful termination, TCP/UDP
|
||||||
|
release, replacement startup on the same endpoints, UDP readiness, and the
|
||||||
|
15-second process-ready RTO. Cold-standby activation policy and the 90-second
|
||||||
|
operator-to-host recovery objective still require a deployment drill before
|
||||||
|
release. Rollout and rollback use the
|
||||||
|
deployment runbook's drain, stop, socket-release, start, smoke sequence; never
|
||||||
|
overlap old and new active processes.
|
||||||
|
|
||||||
|
Bring shared TTL/CAS state and deterministic mediator routing forward before
|
||||||
|
enabling two active instances if any of these occurs:
|
||||||
|
|
||||||
|
- one node cannot sustain 150% of the measured 30-day peak while meeting SLOs;
|
||||||
|
- CPU stays above 70%, memory above 75%, attempt depth above 70%, or limiter
|
||||||
|
drops/latency remain elevated after abusive traffic is excluded;
|
||||||
|
- the availability target rises above 99.5% or planned maintenance must preserve
|
||||||
|
listings; or
|
||||||
|
- one region requires multiple simultaneously active mediator endpoints.
|
||||||
|
|
||||||
|
Rendezvous makes no multi-instance claim today, so a two-node atomic-pairing
|
||||||
|
test is intentionally not applicable. It becomes a hard release gate with the
|
||||||
|
shared-state/routing implementation; until then `SingleActiveInstance=false`
|
||||||
|
fails production startup. Multi-region and relay remain separate evidence-driven
|
||||||
|
decisions.
|
||||||
@@ -0,0 +1,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 1–600 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 1–600.
|
||||||
|
This removes that principal's active listings and attempts and blocks new ones
|
||||||
|
for the bounded lifetime.
|
||||||
|
- Choose the narrowest action. Do not revoke a tenant key for a single listing.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- The game owner resolves the ban, account, workload, or configuration issue in
|
||||||
|
the authoritative game system. Rendezvous does not own user accounts or bans.
|
||||||
|
- After the original revocation deadline, permit a newly authenticated publisher
|
||||||
|
to register. There is no un-revoke endpoint and no recovery of removed
|
||||||
|
ephemeral listings; the host creates a new listing.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Confirm the old listing is no longer browsable or joinable, the principal
|
||||||
|
cannot publish during its lifetime, and the audit action/result is present
|
||||||
|
without the raw target.
|
||||||
|
- Confirm unrelated publishers in the same tenant and another tenant still pass
|
||||||
|
publish/browse/join/direct-traffic checks.
|
||||||
|
|
||||||
|
## Planned restart or crash recovery
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Planned restart begins with a recorded change and a healthy current baseline.
|
||||||
|
Crash recovery begins when liveness/process state fails or both TCP 8080 and
|
||||||
|
UDP 9050 stop answering. Distinguish dependency/readiness failure from a dead
|
||||||
|
process; liveness deliberately remains healthy for some recoverable failures.
|
||||||
|
- Record active listing/lease/attempt aggregates. They are informational only:
|
||||||
|
v1 has no durable runtime database to restore.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- For a planned stop, call `POST /v1/operator/drain` with confirmation exactly
|
||||||
|
`DRAIN`. Require readiness `503`, liveness `200`, and removal from new traffic.
|
||||||
|
Allow the bounded drain deadline to finish, then send SIGTERM.
|
||||||
|
- Never start a second active instance while the old process owns the advertised
|
||||||
|
HTTP/UDP endpoints. On crash, fence the old process/host and verify both sockets
|
||||||
|
are released before replacement.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Start exactly one instance from the recorded immutable image digest and matching
|
||||||
|
reviewed configuration/key references. A restart intentionally loses listings,
|
||||||
|
observed endpoints, attempts, replay markers, and runtime-only revocations.
|
||||||
|
- Ensure any emergency key revocation is also present in authoritative
|
||||||
|
provisioning. Hosts must re-register; clients must browse and start new
|
||||||
|
attempts. Do not restore stale ephemeral state from logs or backups.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Require live and ready health, UDP bind, store availability, and one active
|
||||||
|
target. Run the full deployment smoke and confirm host re-registration begins.
|
||||||
|
- Verify no pre-restart listing or capability is accepted, runtime revocations
|
||||||
|
that should persist are configuration-backed, and latency/outcomes stabilize.
|
||||||
|
|
||||||
|
## Release rollback
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Trigger rollback from a predeclared objective: readiness loss, failed deployment
|
||||||
|
smoke, contract/package incompatibility, security regression, direct-success
|
||||||
|
regression beyond threshold, or sustained resource regression. Record the new
|
||||||
|
and previous digests and the evidence; do not move a tag.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- Stop promotion and new rollout work. Drain and stop the faulty single active
|
||||||
|
instance, then verify both public sockets are released. Revoke affected keys or
|
||||||
|
principals only when the defect creates an authorization risk.
|
||||||
|
- Preserve logs, artifacts, provenance, signatures, and the faulty release record.
|
||||||
|
Never overwrite or delete an immutable package/image to reuse its version.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Deploy the previous known-good image by digest with its compatible configuration
|
||||||
|
and key set. Do not run old and new concurrently. If configuration changed,
|
||||||
|
apply its reviewed down-migration before starting.
|
||||||
|
- Publish a corrected build under a new SemVer after diagnosis; mark faulty release
|
||||||
|
notes withdrawn when appropriate.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Check the running image digest, live/ready health, one active target, UDP source
|
||||||
|
preservation, and the complete TestClient deployment smoke.
|
||||||
|
- Confirm package/server compatibility from `GET /v1/operator/status`, hosts
|
||||||
|
re-register, and the rollback objective returns to baseline for the observation
|
||||||
|
window.
|
||||||
|
|
||||||
|
## Capacity saturation
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Page when `rendezvous.queue.depth` remains above 90% of the configured attempt
|
||||||
|
limit, lease-critical work is shed, `rendezvous.store.available` is zero, or no
|
||||||
|
ready instance remains. Warn at 70%, sustained `rendezvous.limiter.drops`, or
|
||||||
|
p95 latency above objective.
|
||||||
|
- Compare CPU, memory, file descriptors, UDP errors, expiry churn, HTTP operation
|
||||||
|
rate, and typed connection outcomes with the measured
|
||||||
|
[capacity profile](capacity-and-resilience.md). Distinguish legitimate growth,
|
||||||
|
attack traffic, downstream telemetry pressure, and a regression.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- Preserve lease-critical and operator reserves. Shed new browse/join work with
|
||||||
|
the existing typed `429`/`Retry-After` behavior; do not add an unbounded queue.
|
||||||
|
- Apply per-tenant/source controls at the appropriate trusted boundary. If the
|
||||||
|
process is unstable, drain new work and recover on one replacement rather than
|
||||||
|
adding a second active replica; v1 state is process-local.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Remove the causal load or deploy a tested higher single-instance resource and
|
||||||
|
budget profile. Change CPU/memory and server limits together, using the numeric
|
||||||
|
gate and accelerated soak before production.
|
||||||
|
- Long-term horizontal scaling requires a designed shared directory, replay, and
|
||||||
|
attempt authority. A generic load balancer is not that design.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Re-run the capacity/resilience gate at the chosen profile, then require queue,
|
||||||
|
limiter drops, expiry churn, latency, store health, and direct-success ratio to
|
||||||
|
remain within objectives through the production observation window.
|
||||||
|
- Confirm termination still completes within `DrainDeadlineSeconds + 5` and the
|
||||||
|
public and operator partitions behave independently.
|
||||||
|
|
||||||
|
## Privacy or telemetry incident
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Trigger on any credential, token, capability, player identity, raw IP/endpoint,
|
||||||
|
listing ID, metadata, or caller-reported exact connection duration tied to an
|
||||||
|
event or identity found in logs, metrics, traces, crash reports, support systems,
|
||||||
|
or analytics. Aggregate HTTP/UDP duration histograms with bounded operation tags
|
||||||
|
are expected telemetry. Also trigger when audit data exceeds its approved 30-day
|
||||||
|
retention without an incident hold.
|
||||||
|
- Identify the producing version, sink, access population, retention/replication
|
||||||
|
path, and time window without copying the exposed value into a new system.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- Stop or filter the offending export and restrict access to affected sinks.
|
||||||
|
Preserve the minimum evidence under the incident process; do not take broad
|
||||||
|
diagnostic dumps that amplify exposure.
|
||||||
|
- Revoke exposed reusable credentials/keys through their owning boundary. Listing
|
||||||
|
IDs and endpoints are not authentication secrets, but remove affected listings
|
||||||
|
if continued exposure creates risk. Notify privacy/security owners according to
|
||||||
|
applicable policy and law.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Patch the producer to the allowlisted telemetry model, test canary redaction
|
||||||
|
across logs/metrics/traces/output, and deploy through the immutable release
|
||||||
|
path. Delete or age out affected data from every sink according to approved
|
||||||
|
retention and legal-hold direction.
|
||||||
|
- Replace exposed credentials and re-register hosts when necessary. Do not claim
|
||||||
|
that a service restart deletes copies already exported to collectors.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Search new telemetry using non-secret synthetic canaries and confirm no canary
|
||||||
|
or prohibited field crosses the boundary. Verify audit records contain only
|
||||||
|
fixed fields and fingerprints and that retention/eviction is operating.
|
||||||
|
- Security/privacy owners confirm sink cleanup, access review, notification, and
|
||||||
|
monitoring closure before the incident is resolved.
|
||||||
|
|
||||||
|
## Dependency or base-image upgrade
|
||||||
|
|
||||||
|
### Detect
|
||||||
|
|
||||||
|
- Open a reviewed change for an advisory, end-of-support date, pinned-digest
|
||||||
|
refresh, or planned package update. Record affected package/image, current and
|
||||||
|
proposed exact version/digest, advisory severity, exploitability, and required
|
||||||
|
deadline. Never float to `latest` as remediation.
|
||||||
|
|
||||||
|
### Contain
|
||||||
|
|
||||||
|
- For an actively exploited critical issue, restrict exposure or stop the service
|
||||||
|
under incident authority while building the fix. Revoking publisher keys does
|
||||||
|
not repair a vulnerable runtime. Otherwise keep the known-good release running
|
||||||
|
while the candidate is tested.
|
||||||
|
|
||||||
|
### Recover
|
||||||
|
|
||||||
|
- Update the SDK/base-image digest, lock files, license/advisory evidence, SBOM,
|
||||||
|
compatibility matrix, and release notes together. For LiteNetLib or a wire/API
|
||||||
|
change, apply the explicit version/migration policy rather than silently
|
||||||
|
replacing compatible bytes.
|
||||||
|
- Run locked restore, formatting, Debug and Release builds/tests, public contract
|
||||||
|
and package gates, real consumer restores, reproducible artifact/image builds,
|
||||||
|
vulnerability scan, signatures, topology/deployment smoke, and capacity checks
|
||||||
|
proportional to the change. Promote the exact tested digest.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
- Verify signatures, provenance, checksums, SBOM contents, running digest, and
|
||||||
|
absence of the advisory in the shipped artifact—not merely the build host.
|
||||||
|
- Require live/ready health, TestClient direct traffic, real consumer compatibility,
|
||||||
|
and normal latency/outcomes. Keep the previous digest and compatible config for
|
||||||
|
rollback until the observation window closes.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Observability and operator reference
|
||||||
|
|
||||||
|
This runbook defines the production signals and privileged controls for the
|
||||||
|
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
|
||||||
|
from the `FinalFactory.Rendezvous` meter and distributed-tracing activities from
|
||||||
|
`FinalFactory.Rendezvous.Server`. Connect those sources to the deployment's
|
||||||
|
OpenTelemetry or equivalent collector. Do not add identifiers to metric labels.
|
||||||
|
|
||||||
|
Concrete detect/contain/recover/verify procedures for abuse, key compromise,
|
||||||
|
targeted revocation, restart, rollback, saturation, privacy incidents, and
|
||||||
|
dependency upgrades are in the [incident and change runbooks](incident-runbooks.md).
|
||||||
|
|
||||||
|
## Health and readiness
|
||||||
|
|
||||||
|
- `GET /health/live` proves that the HTTP process can answer. It deliberately
|
||||||
|
remains independent of provisioning, the state store, drain state, and optional
|
||||||
|
listeners so an orchestrator does not restart a recoverable dependency failure.
|
||||||
|
- `GET /health/ready` returns success only after the HTTP path is answering, the
|
||||||
|
required IPv4 UDP socket is bound, any configured IPv6 UDP socket is bound,
|
||||||
|
provisioning loaded successfully, the store is available, and drain has not
|
||||||
|
started. A failed check returns `503` and removes the instance from new work.
|
||||||
|
- A graceful drain immediately makes readiness fail while liveness remains healthy.
|
||||||
|
Existing work may complete until the bounded store drain deadline.
|
||||||
|
|
||||||
|
## Metrics and traces
|
||||||
|
|
||||||
|
| Instrument | Purpose | Bounded dimensions |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `rendezvous.http.requests` / `rendezvous.http.duration` | HTTP volume and latency | operation, status code |
|
||||||
|
| `rendezvous.udp.results` / `rendezvous.udp.duration` | UDP mediation volume and processing latency | frozen/litenet operation, result |
|
||||||
|
| `rendezvous.limiter.drops` | Requests shed by admission controls | transport, fixed partition class |
|
||||||
|
| `rendezvous.operator.authentication` | Accepted, forbidden, and rejected operator authentication | result |
|
||||||
|
| `rendezvous.audit.events` | Privileged action outcomes | fixed action, result |
|
||||||
|
| `rendezvous.connection.outcomes` | Client-reported direct-connect outcomes | normalized outcome, elapsed bucket |
|
||||||
|
| `rendezvous.pairing.latency` | Time from attempt creation to successful peer introduction | none |
|
||||||
|
| `rendezvous.queue.depth` | Active join-attempt queue depth | none |
|
||||||
|
| `rendezvous.store.active_listings` / `active_leases` / `active_attempts` / `replay_markers` | Current ephemeral load | none |
|
||||||
|
| `rendezvous.store.expiry_churn` | Cumulative natural expiry activity | none |
|
||||||
|
| `rendezvous.store.available` | Store health (`1` available, `0` unavailable) | none |
|
||||||
|
|
||||||
|
HTTP responses include `X-Rendezvous-Correlation-ID`. It is a generated trace ID
|
||||||
|
or random value, never a caller-supplied session or player identifier. UDP and
|
||||||
|
HTTP activities contain operation-level data only. Logs and traces must not add
|
||||||
|
tokens, capabilities, session/listing IDs, player subjects, metadata, raw IP
|
||||||
|
addresses, or endpoint values.
|
||||||
|
|
||||||
|
Recommended dashboard panels are request rate and p50/p95/p99 latency by fixed
|
||||||
|
operation, UDP result ratio, direct connection success ratio, pairing latency,
|
||||||
|
active listings/attempts, expiry churn, limiter drops, store availability,
|
||||||
|
operator authentication results, audit action results, and signing-key windows.
|
||||||
|
|
||||||
|
## Alerts
|
||||||
|
|
||||||
|
Tune thresholds from the normal production baseline, then keep these conditions
|
||||||
|
as distinct actionable alerts:
|
||||||
|
|
||||||
|
- **Signing key expiry:** page when any required signing key has less than seven
|
||||||
|
days before `signUntil`; escalate at 24 hours. Confirm a replacement is signing
|
||||||
|
and the previous key remains verify-only for the maximum credential lifetime.
|
||||||
|
- **Authentication spike:** warn when rejected or forbidden operator authentication
|
||||||
|
exceeds five attempts in five minutes. Treat unexpected publisher-authentication
|
||||||
|
growth as a possible credential or integration incident.
|
||||||
|
- **Direct success regression:** warn when the connected outcome ratio falls more
|
||||||
|
than 20% below its seven-day same-region baseline for 15 minutes, with a minimum
|
||||||
|
sample floor. Break down only by bounded outcome and time bucket.
|
||||||
|
- **Saturation:** warn when queue depth remains above 70% of the configured attempt
|
||||||
|
limit, limiter drops are sustained, or p95 latency exceeds the service objective;
|
||||||
|
page at 90% or when lease-critical traffic is shed.
|
||||||
|
- **Store degradation:** page immediately when `rendezvous.store.available` is zero
|
||||||
|
or readiness fails for the store. Rising expiry churn without corresponding new
|
||||||
|
work is a warning for stalled clients or clock/configuration mistakes.
|
||||||
|
- **Listener/config readiness:** page when no ready instances remain. Investigate
|
||||||
|
UDP bind failures, a configured-but-unbound IPv6 listener, provisioning errors,
|
||||||
|
and unintended drain state separately.
|
||||||
|
|
||||||
|
## Operator authentication and controls
|
||||||
|
|
||||||
|
Operator credentials use a signing key configured with `CredentialKinds:
|
||||||
|
["Operator"]`. Operator keys cannot be scoped to a game/environment or used for
|
||||||
|
publisher credentials. Mint short-lived operator credentials through the trusted
|
||||||
|
provisioning process, outside the public Rendezvous HTTP service, and grant only
|
||||||
|
the required permission. Never place credentials in command history, URLs, logs,
|
||||||
|
or support tickets.
|
||||||
|
|
||||||
|
The application also enforces a default-deny source boundary. Configure at most
|
||||||
|
32 exact operator source IPs in
|
||||||
|
`Rendezvous:AbuseProtection:OperatorAllowedAddresses`; an empty list disables all
|
||||||
|
operator HTTP access. Development permits loopback only. Production must place
|
||||||
|
`/v1/operator/*` behind a private management listener or reverse-proxy ACL, list
|
||||||
|
only the resulting trusted management source addresses, and block that path on
|
||||||
|
the public edge. If forwarded headers are enabled, keep the existing exact-proxy,
|
||||||
|
single-hop trust policy and allowlist the post-forwarding operator source. Verify
|
||||||
|
from both an allowed management host and a denied public host before deployment.
|
||||||
|
Denied sources are charged to the bounded general HTTP partition before credential
|
||||||
|
or request-body processing, then receive `404`; sustained denied traffic receives
|
||||||
|
the same typed `429` overload response as other public traffic.
|
||||||
|
|
||||||
|
Operator traffic has a dedicated, bounded rate/concurrency partition and critical
|
||||||
|
tracker-key reserve. Public browse/join saturation therefore cannot consume the
|
||||||
|
operator control budget, while compromised management sources remain rate-limited.
|
||||||
|
|
||||||
|
The OpenAPI document defines the separate `OperatorBearer` scheme. All endpoints
|
||||||
|
are under `/v1/operator`:
|
||||||
|
|
||||||
|
| Endpoint | Permission | Confirmation |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /status` | `ReadPolicy` | none; returns aggregates, tenant status, safe key status, and audit counts |
|
||||||
|
| `POST /listings/revoke` | `RevokePublisher` | repeat the exact listing ID in `confirmListingId` |
|
||||||
|
| `POST /principals/revoke` | `RevokePublisher` | repeat the exact subject and choose a 1-600 second revocation lifetime |
|
||||||
|
| `POST /keys/revoke` | `RotateKeys` | repeat the exact key ID; runtime revocation is immediate |
|
||||||
|
| `POST /drain` | `ManagePolicy` | send the exact value `DRAIN` |
|
||||||
|
|
||||||
|
Publisher credentials are rejected on this surface even if their subject resembles
|
||||||
|
an operator. Destructive responses do not echo identifiers. The status response
|
||||||
|
does not expose player identities, raw endpoints, session metadata, capabilities,
|
||||||
|
or tokens. Every authenticated operator action, rejected confirmation, and
|
||||||
|
permission denial is audited with actor and target fingerprints.
|
||||||
|
|
||||||
|
Key revocation is process-local in the current single-instance store. Apply the
|
||||||
|
same revocation to every instance, then replace configuration before restarting;
|
||||||
|
a restart reconstructs the configured key ring. Principal revocation is bounded
|
||||||
|
to ten minutes and removes that principal's active listings and attempts. A
|
||||||
|
repeat action may extend an active revocation but never shortens it; wait for its
|
||||||
|
original deadline rather than treating a shorter repeat as an un-revoke. Use
|
||||||
|
listing revocation for one targeted session and drain before planned shutdown.
|
||||||
|
|
||||||
|
## Audit retention and incident handling
|
||||||
|
|
||||||
|
The in-process audit trail defaults to 10,000 entries and 30 days. It evicts the
|
||||||
|
oldest record at capacity and purges expired records on the next write. Configure
|
||||||
|
`Rendezvous:Audit:MaxEntries` and `RetentionDays` within their validated bounds.
|
||||||
|
Export the structured `AuditTrail` log events through the deployment's protected
|
||||||
|
logging pipeline when durable retention is required; the in-memory trail is not a
|
||||||
|
durable compliance archive. Those events include only timestamps, fixed action
|
||||||
|
fields, correlation IDs, and actor/target fingerprints.
|
||||||
|
|
||||||
|
Audit records retain timestamp, fixed action/result, target kind, correlation ID,
|
||||||
|
and 96-bit SHA-256 fingerprints of actor and target. Routine logs contain only the
|
||||||
|
fixed action/result/target kind and correlation ID. Restrict audit access to the
|
||||||
|
operator role, retain aggregates only as long as operationally necessary, and
|
||||||
|
delete raw exported audit data according to the 30-day policy unless an incident
|
||||||
|
hold is approved.
|
||||||
|
|
||||||
|
During an incident: confirm readiness and store health; capture aggregate graphs
|
||||||
|
and correlation IDs; revoke the narrowest listing, principal, or key; drain only
|
||||||
|
when isolation is required; record the action in the incident timeline; and verify
|
||||||
|
that direct success, limiter drops, and authentication rates return to baseline.
|
||||||
|
Do not copy player data, endpoints, or credentials into the incident record.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ traffic is also isolated by its authenticated scope.
|
|||||||
Health probes use their own source-prefix budget so public API overload cannot
|
Health probes use their own source-prefix budget so public API overload cannot
|
||||||
make a healthy instance fail its orchestrator probes, while health traffic is
|
make a healthy instance fail its orchestrator probes, while health traffic is
|
||||||
still bounded.
|
still bounded.
|
||||||
|
Operator endpoints likewise use a separate bounded rate/concurrency partition
|
||||||
|
backed by the critical tracker reserve. They first require an exact source IP
|
||||||
|
from the default-deny `OperatorAllowedAddresses` policy, so public traffic
|
||||||
|
cannot spend the incident-response budget.
|
||||||
3. Once an endpoint has safely derived identities, it also acquires applicable
|
3. Once an endpoint has safely derived identities, it also acquires applicable
|
||||||
tenant, principal or capability, and listing/attempt budgets. Secret
|
tenant, principal or capability, and listing/attempt budgets. Secret
|
||||||
capabilities are represented only by bounded SHA-256 fingerprints.
|
capabilities are represented only by bounded SHA-256 fingerprints.
|
||||||
@@ -41,7 +45,8 @@ load shedding.
|
|||||||
|
|
||||||
`Rendezvous:AbuseProtection:MaxTrackedKeys` is a hard combined ceiling for rate
|
`Rendezvous:AbuseProtection:MaxTrackedKeys` is a hard combined ceiling for rate
|
||||||
and active-concurrency keys. General HTTP and UDP traffic cannot consume the
|
and active-concurrency keys. General HTTP and UDP traffic cannot consume the
|
||||||
configured `CriticalTrackedKeyReserve`; lease operations and health probes may
|
configured `CriticalTrackedKeyReserve`; lease operations, health probes, and
|
||||||
|
allowlisted operator controls may
|
||||||
use that reserve but never exceed the hard ceiling. A request that would exceed
|
use that reserve but never exceed the hard ceiling. A request that would exceed
|
||||||
its applicable ceiling fails closed without adding state. Fixed-window rate keys
|
its applicable ceiling fails closed without adding state. Fixed-window rate keys
|
||||||
are cleared at the next window boundary; concurrency keys are removed as their
|
are cleared at the next window boundary; concurrency keys are removed as their
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ only its public key ID/lifecycle metadata and does not require retired secret
|
|||||||
material to remain available.
|
material to remain available.
|
||||||
|
|
||||||
Key IDs are non-secret base64url identifiers. Secret references are resolved
|
Key IDs are non-secret base64url identifiers. Secret references are resolved
|
||||||
through `ISecretProvider`; production supports `env:<VARIABLE>` references and
|
through `ISecretProvider`; production supports base64 `env:<VARIABLE>` and raw
|
||||||
the interface is replaceable by a deployment-specific vault/KMS adapter. The
|
`file:/absolute/path` references to bounded non-symlink files. The interface is
|
||||||
|
replaceable by a deployment-specific vault/KMS adapter. The
|
||||||
committed development profile uses an in-memory random key identified by a
|
committed development profile uses an in-memory random key identified by a
|
||||||
`development:ephemeral/...` reference. It never writes key material to disk and
|
`development:ephemeral/...` reference. It never writes key material to disk and
|
||||||
all credentials become invalid when the process exits.
|
all credentials become invalid when the process exits.
|
||||||
@@ -74,8 +75,10 @@ all credentials become invalid when the process exits.
|
|||||||
`Rendezvous:Provisioning` supplies issuer, audience, clock skew, signing-key
|
`Rendezvous:Provisioning` supplies issuer, audience, clock skew, signing-key
|
||||||
descriptors, and game policies. A production key reference such as
|
descriptors, and game policies. A production key reference such as
|
||||||
`env:RENDEZVOUS_SIGNING_KEY_2026_01` expects that environment variable to hold at
|
`env:RENDEZVOUS_SIGNING_KEY_2026_01` expects that environment variable to hold at
|
||||||
least 32 random bytes encoded as base64. Missing, malformed, short, inactive, or
|
least 32 random bytes encoded as base64. `file:/run/secrets/rendezvous-signing`
|
||||||
duplicate keys stop startup with a key-ID-only diagnostic. No game-wide secret
|
expects the raw bytes in a read-only, absolute, non-symlink file. Missing,
|
||||||
|
malformed, short, inactive, or duplicate keys stop startup with a key-ID-only
|
||||||
|
diagnostic. No game-wide secret
|
||||||
belongs in `appsettings`, source control, examples, the Client package, URLs,
|
belongs in `appsettings`, source control, examples, the Client package, URLs,
|
||||||
responses, logs, metrics, exceptions, or diagnostic dumps.
|
responses, logs, metrics, exceptions, or diagnostic dumps.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"consumers": [
|
||||||
|
{
|
||||||
|
"name": "SpaceGame",
|
||||||
|
"repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git",
|
||||||
|
"revision": "77519b0cc418a27f8d408ae2d7b8812fbe087c04",
|
||||||
|
"project": "SpaceGame.csproj"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unscouted",
|
||||||
|
"repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git",
|
||||||
|
"revision": "7807dbee86eb8b98e702f1eb89c88adff728f635",
|
||||||
|
"project": "Net.Core/Net.Core.csproj"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||||
|
FROM python:3.12.11-slim-bookworm@sha256:c00fc7b44d844b6da22861ec24af43968a5200eac4ec607b4725d585165d6b49 AS release-python
|
||||||
|
FROM ghcr.io/jqlang/jq:1.8.1@sha256:95de8f005ca027686a1ca3b0853e2bb219062438015862816159f3f25a4d4230 AS release-jq
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS release-builder
|
||||||
|
COPY --from=release-python /usr/local/ /usr/local/
|
||||||
|
COPY --from=release-jq /jq /usr/local/bin/jq
|
||||||
|
RUN dotnet --version \
|
||||||
|
&& python3 --version \
|
||||||
|
&& git --version \
|
||||||
|
&& tar --version \
|
||||||
|
&& gzip --version \
|
||||||
|
&& jq --version
|
||||||
|
WORKDIR /source
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
Executable
+253
@@ -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"
|
||||||
Executable
+56
@@ -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."
|
||||||
Executable
+27
@@ -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
|
||||||
Executable
+26
@@ -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"
|
||||||
Executable
+23
@@ -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"
|
||||||
Executable
+96
@@ -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
|
||||||
Executable
+153
@@ -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"
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROFILE="${RENDEZVOUS_CAPACITY_PROFILE:-quick}"
|
||||||
|
OUTPUT="${RENDEZVOUS_CAPACITY_OUTPUT:-$ROOT/artifacts/capacity/rendezvous-capacity-v2.json}"
|
||||||
|
CPUSET="${RENDEZVOUS_CAPACITY_CPUSET:-}"
|
||||||
|
PROJECT="$ROOT/tests/FinalFactory.Rendezvous.Capacity/FinalFactory.Rendezvous.Capacity.csproj"
|
||||||
|
TESTS="$ROOT/tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj"
|
||||||
|
|
||||||
|
if [[ "$PROFILE" != quick && "$PROFILE" != candidate ]]; then
|
||||||
|
printf 'RENDEZVOUS_CAPACITY_PROFILE must be quick or candidate.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
command -v dotnet >/dev/null || {
|
||||||
|
printf 'Missing required command: dotnet\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
if [[ -n "$CPUSET" ]]; then
|
||||||
|
command -v taskset >/dev/null || {
|
||||||
|
printf 'taskset is required when RENDEZVOUS_CAPACITY_CPUSET is set.\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
export RENDEZVOUS_EVIDENCE_COMMIT="$(git rev-parse HEAD)"
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
export RENDEZVOUS_EVIDENCE_TREE_STATE=dirty
|
||||||
|
else
|
||||||
|
export RENDEZVOUS_EVIDENCE_TREE_STATE=clean
|
||||||
|
fi
|
||||||
|
if [[ "$PROFILE" == candidate && "$RENDEZVOUS_EVIDENCE_TREE_STATE" != clean ]]; then
|
||||||
|
printf 'Candidate evidence requires a clean source tree.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
export RENDEZVOUS_EVIDENCE_CPUSET="${CPUSET:-unrestricted}"
|
||||||
|
export RENDEZVOUS_EVIDENCE_COMMAND="RENDEZVOUS_CAPACITY_PROFILE=$PROFILE RENDEZVOUS_CAPACITY_CPUSET=${CPUSET:-unrestricted} ./scripts/run-capacity-gate.sh"
|
||||||
|
|
||||||
|
dotnet restore "$ROOT/Rendezvous.slnx" --locked-mode
|
||||||
|
dotnet build "$ROOT/Rendezvous.slnx" --configuration Release --no-restore
|
||||||
|
|
||||||
|
filter='FullyQualifiedName~TrackerCapacityFailsClosedWithoutGrowingAndAWindowResetRecovers|FullyQualifiedName~OptionalTrafficCannotConsumeTheLeaseOperationReserve|FullyQualifiedName~ConcurrentAbusiveBurstStaysBoundedAndCannotBlockCriticalHttp|FullyQualifiedName~HttpOverloadIsTypedAndOversizedBodiesAreRejectedBeforeDispatch|FullyQualifiedName~WallClockMovementDoesNotExpireOrExtendLease|FullyQualifiedName~RepeatedMutableDeadlineRefreshesKeepOneScheduledEntryPerKey|FullyQualifiedName~RepeatedPrincipalRevocationCanExtendButCannotShortenProtection|FullyQualifiedName~RestartHasNewGenerationAndNoEphemeralState|FullyQualifiedName~RestartReturnsTypedUnavailabilityThenAllowsHostReregistration|FullyQualifiedName~DrainRejectsNewWorkAllowsInflightCompletionThenClearsState|FullyQualifiedName~UnavailableStoreFailsNewAuthorizationClosedAndErasesActiveState|FullyQualifiedName~KeyRotationHonorsOverlapAndRejectsRetiredKeys|FullyQualifiedName~OperatorSurfaceSeparatesAuthenticationConfirmsActionsAndRedactsInspection|FullyQualifiedName~SigtermDrainsThenReleasesHttpAndUdpSockets|FullyQualifiedName~ProductionTransportSoakKeepsHandlesMemoryAndSocketsBounded|FullyQualifiedName~NativeLiteNetLibRequestsIntroduceTheAuthorizedPair'
|
||||||
|
dotnet test "$TESTS" --configuration Release --no-build --filter "$filter" \
|
||||||
|
--logger 'console;verbosity=minimal'
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUTPUT")"
|
||||||
|
arguments=(
|
||||||
|
dotnet run --project "$PROJECT" --configuration Release --no-build --
|
||||||
|
--profile "$PROFILE" --output "$OUTPUT"
|
||||||
|
)
|
||||||
|
if [[ -n "$CPUSET" ]]; then
|
||||||
|
taskset -c "$CPUSET" "${arguments[@]}"
|
||||||
|
else
|
||||||
|
"${arguments[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Capacity and resilience gate passed; evidence: %s\n' "$OUTPUT"
|
||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SERVICE_URL="${RENDEZVOUS_SMOKE_HTTP_URL:-http://127.0.0.1:8080/}"
|
||||||
|
MEDIATOR="${RENDEZVOUS_SMOKE_UDP_ENDPOINT:-127.0.0.1:9050}"
|
||||||
|
TIMEOUT_SECONDS="${RENDEZVOUS_SMOKE_TIMEOUT_SECONDS:-30}"
|
||||||
|
LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}"
|
||||||
|
PROJECT="$ROOT/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj"
|
||||||
|
BUILD_CONFIGURATION="${RENDEZVOUS_SMOKE_CONFIGURATION:-Release}"
|
||||||
|
GAME_ID="${RENDEZVOUS_SMOKE_GAME_ID:-space-game}"
|
||||||
|
ENVIRONMENT_ID="${RENDEZVOUS_SMOKE_ENVIRONMENT_ID:-smoke}"
|
||||||
|
REGION="${RENDEZVOUS_SMOKE_REGION:-local}"
|
||||||
|
PROTOCOL_VERSION="${RENDEZVOUS_SMOKE_PROTOCOL_VERSION:-1}"
|
||||||
|
|
||||||
|
for command in curl dotnet jq mktemp tail; do
|
||||||
|
command -v "$command" >/dev/null || {
|
||||||
|
printf 'Missing required command: %s\n' "$command" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || (( TIMEOUT_SECONDS < 1 || TIMEOUT_SECONDS > 300 )); then
|
||||||
|
printf 'RENDEZVOUS_SMOKE_TIMEOUT_SECONDS must be an integer from 1 through 300.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! "$PROTOCOL_VERSION" =~ ^[0-9]+$ ]] || (( PROTOCOL_VERSION < 1 )); then
|
||||||
|
printf 'RENDEZVOUS_SMOKE_PROTOCOL_VERSION must be a positive integer.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
for scoped_value in "$GAME_ID" "$ENVIRONMENT_ID" "$REGION"; do
|
||||||
|
if [[ -z "$scoped_value" ]]; then
|
||||||
|
printf 'Smoke game, environment, and region values must not be empty.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local_credential() {
|
||||||
|
if [[ "$GAME_ID" != space-game || "$ENVIRONMENT_ID" != smoke \
|
||||||
|
|| "$REGION" != local || "$PROTOCOL_VERSION" != 1 ]]; then
|
||||||
|
printf 'The local credential helper supports only space-game/smoke/local protocol 1. Supply RENDEZVOUS_PUBLISHER_CREDENTIAL for any other scope.\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
RENDEZVOUS_SMOKE_LOCAL_KEY="$LOCAL_KEY" \
|
||||||
|
"$ROOT/scripts/mint-local-publisher-credential.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
credential="${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}"
|
||||||
|
if [[ -z "$credential" ]]; then
|
||||||
|
credential="$(local_credential)"
|
||||||
|
fi
|
||||||
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$credential"
|
||||||
|
|
||||||
|
curl --fail --silent --show-error --max-time 5 "${SERVICE_URL%/}/health/live" >/dev/null
|
||||||
|
curl --fail --silent --show-error --max-time 5 "${SERVICE_URL%/}/health/ready" >/dev/null
|
||||||
|
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
host_log="$temp_dir/host.jsonl"
|
||||||
|
join_log="$temp_dir/join.jsonl"
|
||||||
|
host_pid=''
|
||||||
|
cleanup() {
|
||||||
|
local status="$?"
|
||||||
|
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
|
||||||
|
kill -TERM "$host_pid" 2>/dev/null || true
|
||||||
|
wait "$host_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ "$status" -ne 0 ]]; then
|
||||||
|
printf 'Deployment smoke failed; sanitized diagnostic events follow.\n' >&2
|
||||||
|
[[ -f "$host_log" ]] && jq -c . "$host_log" >&2 || true
|
||||||
|
[[ -f "$join_log" ]] && jq -c . "$join_log" >&2 || true
|
||||||
|
fi
|
||||||
|
rm -rf "$temp_dir"
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-build -- \
|
||||||
|
host --service "$SERVICE_URL" --mediator "$MEDIATOR" \
|
||||||
|
--game "$GAME_ID" --environment "$ENVIRONMENT_ID" --region "$REGION" --protocol "$PROTOCOL_VERSION" \
|
||||||
|
--script --json --exit-after-echo --timeout-seconds "$TIMEOUT_SECONDS" \
|
||||||
|
>"$host_log" 2>&1 &
|
||||||
|
host_pid="$!"
|
||||||
|
|
||||||
|
ready=false
|
||||||
|
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
|
||||||
|
if jq -e 'select(.event == "host.ready")' "$host_log" >/dev/null 2>&1; then
|
||||||
|
ready=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$host_pid" 2>/dev/null; then
|
||||||
|
printf 'Host diagnostic stopped before it became ready.\n' >&2
|
||||||
|
jq -c . "$host_log" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
if [[ "$ready" != true ]]; then
|
||||||
|
printf 'Host diagnostic did not become ready within %s seconds.\n' "$TIMEOUT_SECONDS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
listing_id="$(jq -r 'select(.event == "host.registered") | .listingId' "$host_log" | tail -n 1)"
|
||||||
|
if [[ -z "$listing_id" || "$listing_id" == null ]]; then
|
||||||
|
printf 'Host diagnostic did not report a listing ID.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-build -- \
|
||||||
|
join --service "$SERVICE_URL" --mediator "$MEDIATOR" \
|
||||||
|
--game "$GAME_ID" --environment "$ENVIRONMENT_ID" --region "$REGION" --protocol "$PROTOCOL_VERSION" \
|
||||||
|
--listing "$listing_id" --script --json --timeout-seconds "$TIMEOUT_SECONDS" \
|
||||||
|
>"$join_log" 2>&1
|
||||||
|
wait "$host_pid"
|
||||||
|
host_pid=''
|
||||||
|
|
||||||
|
jq -e 'select(.event == "host.direct-traffic" and .status == "verified")' "$host_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$host_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "join.direct-traffic" and .status == "verified")' "$join_log" >/dev/null
|
||||||
|
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$join_log" >/dev/null
|
||||||
|
|
||||||
|
printf 'Rendezvous deployment smoke passed: HTTP live/ready and authenticated UDP mediation/direct traffic.\n'
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/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 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
|
||||||
Executable
+13
@@ -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
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -20,6 +20,8 @@ internal sealed class AbuseProtectionOptions
|
|||||||
|
|
||||||
public string[] TrustedProxyAddresses { get; set; } = [];
|
public string[] TrustedProxyAddresses { get; set; } = [];
|
||||||
|
|
||||||
|
public string[] OperatorAllowedAddresses { get; set; } = [];
|
||||||
|
|
||||||
[Range(1, 100_000)]
|
[Range(1, 100_000)]
|
||||||
public int HealthGlobalRequestsPerWindow { get; set; } = 1_000;
|
public int HealthGlobalRequestsPerWindow { get; set; } = 1_000;
|
||||||
|
|
||||||
@@ -32,6 +34,18 @@ internal sealed class AbuseProtectionOptions
|
|||||||
[Range(1, 1_000)]
|
[Range(1, 1_000)]
|
||||||
public int HealthIpPrefixConcurrency { get; set; } = 8;
|
public int HealthIpPrefixConcurrency { get; set; } = 8;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int OperatorGlobalRequestsPerWindow { get; set; } = 1_000;
|
||||||
|
|
||||||
|
[Range(1, 10_000)]
|
||||||
|
public int OperatorGlobalConcurrency { get; set; } = 32;
|
||||||
|
|
||||||
|
[Range(1, 100_000)]
|
||||||
|
public int OperatorIpPrefixRequestsPerWindow { get; set; } = 120;
|
||||||
|
|
||||||
|
[Range(1, 1_000)]
|
||||||
|
public int OperatorIpPrefixConcurrency { get; set; } = 8;
|
||||||
|
|
||||||
[Range(1, 1_000_000)]
|
[Range(1, 1_000_000)]
|
||||||
public int HttpGlobalRequestsPerWindow { get; set; } = 20_000;
|
public int HttpGlobalRequestsPerWindow { get; set; } = 20_000;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Buffers;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Abuse;
|
namespace FinalFactory.Rendezvous.Server.Abuse;
|
||||||
@@ -12,13 +13,23 @@ internal sealed class AbuseProtectionService
|
|||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly TrackerState _httpTracker;
|
private readonly TrackerState _httpTracker;
|
||||||
private readonly TrackerState _udpTracker;
|
private readonly TrackerState _udpTracker;
|
||||||
|
private readonly RendezvousTelemetry? _telemetry;
|
||||||
|
private readonly HashSet<string> _operatorAllowedAddresses;
|
||||||
|
|
||||||
public AbuseProtectionService(
|
public AbuseProtectionService(
|
||||||
IOptions<AbuseProtectionOptions> options,
|
IOptions<AbuseProtectionOptions> options,
|
||||||
TimeProvider? timeProvider = null)
|
TimeProvider? timeProvider = null,
|
||||||
|
RendezvousTelemetry? telemetry = null)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_telemetry = telemetry;
|
||||||
|
_operatorAllowedAddresses = options.Value.OperatorAllowedAddresses
|
||||||
|
.Select(static value => IPAddress.TryParse(value, out IPAddress? address)
|
||||||
|
? NormalizeAddress(address).ToString()
|
||||||
|
: string.Empty)
|
||||||
|
.Where(static value => value.Length > 0)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||||
_httpTracker = new(now);
|
_httpTracker = new(now);
|
||||||
_udpTracker = new(now);
|
_udpTracker = new(now);
|
||||||
@@ -87,6 +98,35 @@ internal sealed class AbuseProtectionService
|
|||||||
out retryAfterSeconds);
|
out retryAfterSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsOperatorSourceAllowed(IPAddress? remoteAddress) =>
|
||||||
|
remoteAddress is not null
|
||||||
|
&& _operatorAllowedAddresses.Contains(NormalizeAddress(remoteAddress).ToString());
|
||||||
|
|
||||||
|
public bool TryAcquireOperatorIngress(
|
||||||
|
IPAddress? remoteAddress,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out int retryAfterSeconds)
|
||||||
|
{
|
||||||
|
string prefix = GetNetworkPrefix(remoteAddress);
|
||||||
|
RateDimension[] rates =
|
||||||
|
[
|
||||||
|
new("operator:rate:global", _options.OperatorGlobalRequestsPerWindow),
|
||||||
|
new($"operator:rate:ip:{prefix}", _options.OperatorIpPrefixRequestsPerWindow),
|
||||||
|
];
|
||||||
|
RateDimension[] concurrency =
|
||||||
|
[
|
||||||
|
new("operator:concurrency:global", _options.OperatorGlobalConcurrency),
|
||||||
|
new($"operator:concurrency:ip:{prefix}", _options.OperatorIpPrefixConcurrency),
|
||||||
|
];
|
||||||
|
return TryAcquire(
|
||||||
|
rates,
|
||||||
|
concurrency,
|
||||||
|
TrackerDomain.Http,
|
||||||
|
true,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
public bool TryAcquireHttpIdentity(
|
public bool TryAcquireHttpIdentity(
|
||||||
string operation,
|
string operation,
|
||||||
string? tenant,
|
string? tenant,
|
||||||
@@ -266,67 +306,96 @@ internal sealed class AbuseProtectionService
|
|||||||
out int retryAfterSeconds)
|
out int retryAfterSeconds)
|
||||||
{
|
{
|
||||||
TrackerState tracker = domain == TrackerDomain.Udp ? _udpTracker : _httpTracker;
|
TrackerState tracker = domain == TrackerDomain.Udp ? _udpTracker : _httpTracker;
|
||||||
|
bool accepted;
|
||||||
lock (tracker.Gate)
|
lock (tracker.Gate)
|
||||||
{
|
{
|
||||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
accepted = TryAcquireLocked(
|
||||||
TimeSpan window = TimeSpan.FromSeconds(_options.WindowSeconds);
|
tracker,
|
||||||
if (now - tracker.WindowStartedAt >= window || now < tracker.WindowStartedAt)
|
rates,
|
||||||
{
|
concurrency,
|
||||||
tracker.WindowCounts.Clear();
|
domain,
|
||||||
tracker.WindowStartedAt = now;
|
canUseCriticalReserve,
|
||||||
}
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
retryAfterSeconds = Math.Max(
|
if (!accepted)
|
||||||
1,
|
{
|
||||||
(int)Math.Ceiling((window - (now - tracker.WindowStartedAt)).TotalSeconds));
|
_telemetry?.RecordLimiterDrop(
|
||||||
int stagedNewKeys = 0;
|
domain == TrackerDomain.Udp ? "udp" : "http",
|
||||||
int partitionLimit = domain == TrackerDomain.Udp
|
"rate-or-concurrency");
|
||||||
? _options.UdpTrackedKeyLimit
|
}
|
||||||
: _options.MaxTrackedKeys - _options.UdpTrackedKeyLimit;
|
|
||||||
int maxTrackedKeys = domain == TrackerDomain.Udp || canUseCriticalReserve
|
|
||||||
? partitionLimit
|
|
||||||
: partitionLimit - _options.CriticalTrackedKeyReserve;
|
|
||||||
if (!CanAcquireAll(
|
|
||||||
tracker,
|
|
||||||
tracker.WindowCounts,
|
|
||||||
rates,
|
|
||||||
maxTrackedKeys,
|
|
||||||
ref stagedNewKeys)
|
|
||||||
|| !CanAcquireAll(
|
|
||||||
tracker,
|
|
||||||
tracker.ConcurrencyCounts,
|
|
||||||
concurrency,
|
|
||||||
maxTrackedKeys,
|
|
||||||
ref stagedNewKeys))
|
|
||||||
{
|
|
||||||
lease = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (RateDimension dimension in rates)
|
return accepted;
|
||||||
{
|
}
|
||||||
tracker.WindowCounts[dimension.Key] =
|
|
||||||
tracker.WindowCounts.GetValueOrDefault(dimension.Key) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (concurrency.IsEmpty)
|
private bool TryAcquireLocked(
|
||||||
{
|
TrackerState tracker,
|
||||||
lease = null;
|
ReadOnlySpan<RateDimension> rates,
|
||||||
return true;
|
ReadOnlySpan<RateDimension> concurrency,
|
||||||
}
|
TrackerDomain domain,
|
||||||
|
bool canUseCriticalReserve,
|
||||||
|
out AbuseLease? lease,
|
||||||
|
out int retryAfterSeconds)
|
||||||
|
{
|
||||||
|
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||||
|
TimeSpan window = TimeSpan.FromSeconds(_options.WindowSeconds);
|
||||||
|
if (now - tracker.WindowStartedAt >= window || now < tracker.WindowStartedAt)
|
||||||
|
{
|
||||||
|
tracker.WindowCounts.Clear();
|
||||||
|
tracker.WindowStartedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
string[] acquiredConcurrency = new string[concurrency.Length];
|
retryAfterSeconds = Math.Max(
|
||||||
for (int index = 0; index < concurrency.Length; index++)
|
1,
|
||||||
{
|
(int)Math.Ceiling((window - (now - tracker.WindowStartedAt)).TotalSeconds));
|
||||||
RateDimension dimension = concurrency[index];
|
int stagedNewKeys = 0;
|
||||||
tracker.ConcurrencyCounts[dimension.Key] =
|
int partitionLimit = domain == TrackerDomain.Udp
|
||||||
tracker.ConcurrencyCounts.GetValueOrDefault(dimension.Key) + 1;
|
? _options.UdpTrackedKeyLimit
|
||||||
acquiredConcurrency[index] = dimension.Key;
|
: _options.MaxTrackedKeys - _options.UdpTrackedKeyLimit;
|
||||||
}
|
int maxTrackedKeys = domain == TrackerDomain.Udp || canUseCriticalReserve
|
||||||
|
? partitionLimit
|
||||||
|
: partitionLimit - _options.CriticalTrackedKeyReserve;
|
||||||
|
if (!CanAcquireAll(
|
||||||
|
tracker,
|
||||||
|
tracker.WindowCounts,
|
||||||
|
rates,
|
||||||
|
maxTrackedKeys,
|
||||||
|
ref stagedNewKeys)
|
||||||
|
|| !CanAcquireAll(
|
||||||
|
tracker,
|
||||||
|
tracker.ConcurrencyCounts,
|
||||||
|
concurrency,
|
||||||
|
maxTrackedKeys,
|
||||||
|
ref stagedNewKeys))
|
||||||
|
{
|
||||||
|
lease = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
lease = new AbuseLease(this, tracker, acquiredConcurrency);
|
foreach (RateDimension dimension in rates)
|
||||||
|
{
|
||||||
|
tracker.WindowCounts[dimension.Key] =
|
||||||
|
tracker.WindowCounts.GetValueOrDefault(dimension.Key) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (concurrency.IsEmpty)
|
||||||
|
{
|
||||||
|
lease = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string[] acquiredConcurrency = new string[concurrency.Length];
|
||||||
|
for (int index = 0; index < concurrency.Length; index++)
|
||||||
|
{
|
||||||
|
RateDimension dimension = concurrency[index];
|
||||||
|
tracker.ConcurrencyCounts[dimension.Key] =
|
||||||
|
tracker.ConcurrencyCounts.GetValueOrDefault(dimension.Key) + 1;
|
||||||
|
acquiredConcurrency[index] = dimension.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
lease = new AbuseLease(this, tracker, acquiredConcurrency);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool CanAcquireAll(
|
private static bool CanAcquireAll(
|
||||||
@@ -415,6 +484,9 @@ internal sealed class AbuseProtectionService
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IPAddress NormalizeAddress(IPAddress address) =>
|
||||||
|
address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
|
||||||
|
|
||||||
private readonly record struct RateDimension(string Key, int Limit);
|
private readonly record struct RateDimension(string Key, int Limit);
|
||||||
|
|
||||||
private enum TrackerDomain
|
private enum TrackerDomain
|
||||||
|
|||||||
@@ -19,16 +19,71 @@ internal sealed class HttpAbuseProtectionMiddleware(
|
|||||||
string operation = context.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()
|
string operation = context.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()
|
||||||
?.EndpointName ?? "Unmatched";
|
?.EndpointName ?? "Unmatched";
|
||||||
bool healthEndpoint = operation is "GetLiveness" or "GetReadiness";
|
bool healthEndpoint = operation is "GetLiveness" or "GetReadiness";
|
||||||
bool acquired = healthEndpoint
|
bool operatorEndpoint = operation is
|
||||||
? protection.TryAcquireHealthIngress(
|
"GetOperatorStatus"
|
||||||
|
or "RevokeOperatorListing"
|
||||||
|
or "RevokeOperatorPrincipal"
|
||||||
|
or "RevokeOperatorSigningKey"
|
||||||
|
or "BeginOperatorDrain";
|
||||||
|
if (operatorEndpoint
|
||||||
|
&& !protection.IsOperatorSourceAllowed(context.Connection.RemoteIpAddress))
|
||||||
|
{
|
||||||
|
bool deniedSourceAdmitted = protection.TryAcquireHttpIngress(
|
||||||
context.Connection.RemoteIpAddress,
|
context.Connection.RemoteIpAddress,
|
||||||
out AbuseProtectionService.AbuseLease? lease,
|
"Unmatched",
|
||||||
out int retryAfterSeconds)
|
out AbuseProtectionService.AbuseLease? deniedSourceLease,
|
||||||
: protection.TryAcquireHttpIngress(
|
out int deniedRetryAfterSeconds);
|
||||||
|
using (deniedSourceLease)
|
||||||
|
{
|
||||||
|
if (!deniedSourceAdmitted)
|
||||||
|
{
|
||||||
|
context.Response.Headers.RetryAfter = deniedRetryAfterSeconds.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
await WriteErrorAsync(
|
||||||
|
context,
|
||||||
|
StatusCodes.Status429TooManyRequests,
|
||||||
|
RendezvousErrorCode.RateLimited,
|
||||||
|
"The request rate limit was exceeded.",
|
||||||
|
deniedRetryAfterSeconds).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteErrorAsync(
|
||||||
|
context,
|
||||||
|
StatusCodes.Status404NotFound,
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
"The requested resource was not found.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AbuseProtectionService.AbuseLease? lease;
|
||||||
|
int retryAfterSeconds;
|
||||||
|
bool acquired;
|
||||||
|
if (healthEndpoint)
|
||||||
|
{
|
||||||
|
acquired = protection.TryAcquireHealthIngress(
|
||||||
|
context.Connection.RemoteIpAddress,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
else if (operatorEndpoint)
|
||||||
|
{
|
||||||
|
acquired = protection.TryAcquireOperatorIngress(
|
||||||
|
context.Connection.RemoteIpAddress,
|
||||||
|
out lease,
|
||||||
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
acquired = protection.TryAcquireHttpIngress(
|
||||||
context.Connection.RemoteIpAddress,
|
context.Connection.RemoteIpAddress,
|
||||||
operation,
|
operation,
|
||||||
out lease,
|
out lease,
|
||||||
out retryAfterSeconds);
|
out retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
if (!acquired)
|
if (!acquired)
|
||||||
{
|
{
|
||||||
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(
|
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
using FinalFactory.Rendezvous.Server.Sessions;
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
using FinalFactory.Rendezvous.Server.State;
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ internal sealed class ConnectionOutcomeMetrics
|
|||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly Dictionary<(ConnectionOutcomeKind, ConnectionElapsedBucket), long> _counts = [];
|
private readonly Dictionary<(ConnectionOutcomeKind, ConnectionElapsedBucket), long> _counts = [];
|
||||||
|
private readonly RendezvousTelemetry? _telemetry;
|
||||||
|
|
||||||
|
public ConnectionOutcomeMetrics(RendezvousTelemetry? telemetry = null) =>
|
||||||
|
_telemetry = telemetry;
|
||||||
|
|
||||||
internal void Record(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
|
internal void Record(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
|
||||||
{
|
{
|
||||||
@@ -24,6 +29,8 @@ internal sealed class ConnectionOutcomeMetrics
|
|||||||
_counts.TryGetValue(key, out long count);
|
_counts.TryGetValue(key, out long count);
|
||||||
_counts[key] = count + 1;
|
_counts[key] = count + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_telemetry?.RecordConnectionOutcome(outcome.ToString(), elapsedBucket.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
internal long GetCount(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
|
internal long GetCount(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Deployment;
|
||||||
|
|
||||||
|
internal sealed record DeploymentOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Rendezvous:Deployment";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string PublicHttpBaseUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string PublicUdpHost { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[Range(1, 65_535)]
|
||||||
|
public int PublicUdpPort { get; init; } = 9050;
|
||||||
|
|
||||||
|
[Range(1, 30)]
|
||||||
|
public int DrainDeadlineSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
[Range(0, 5)]
|
||||||
|
public int MinimumDrainSeconds { get; init; } = 1;
|
||||||
|
|
||||||
|
public bool SingleActiveInstance { get; init; } = true;
|
||||||
|
|
||||||
|
public bool AllowPrivatePublicEndpoints { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> ValidateProduction(
|
||||||
|
AbuseProtectionOptions abuseProtection,
|
||||||
|
string? allowedHosts)
|
||||||
|
{
|
||||||
|
List<string> errors = [];
|
||||||
|
if (!SingleActiveInstance)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:SingleActiveInstance must be true because ephemeral state is not shared between replicas.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MinimumDrainSeconds >= DrainDeadlineSeconds)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:MinimumDrainSeconds must be less than DrainDeadlineSeconds.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DrainDeadlineSeconds is < 1 or > 30)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:DrainDeadlineSeconds must be between 1 and 30.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MinimumDrainSeconds is < 0 or > 5)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:MinimumDrainSeconds must be between 0 and 5.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PublicUdpPort is < 1 or > 65_535)
|
||||||
|
{
|
||||||
|
errors.Add("Rendezvous:Deployment:PublicUdpPort must be between 1 and 65535.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateHttpEndpoint(errors);
|
||||||
|
ValidateUdpEndpoint(errors);
|
||||||
|
ValidateAllowedHosts(errors, allowedHosts, PublicHttpBaseUrl);
|
||||||
|
|
||||||
|
if (abuseProtection.TrustedProxyAddresses is not { Length: > 0 })
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:AbuseProtection:TrustedProxyAddresses must list the exact TLS proxy addresses; forwarded headers are rejected without this trust boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateHttpEndpoint(List<string> errors)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(PublicHttpBaseUrl, UriKind.Absolute, out Uri? endpoint)
|
||||||
|
|| !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
|
||||||
|
|| !string.IsNullOrEmpty(endpoint.UserInfo)
|
||||||
|
|| !string.IsNullOrEmpty(endpoint.Query)
|
||||||
|
|| !string.IsNullOrEmpty(endpoint.Fragment)
|
||||||
|
|| endpoint.AbsolutePath != "/")
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:Deployment:PublicHttpBaseUrl must be an absolute HTTPS origin with no credentials, path, query, or fragment (for example, https://rendezvous.your-company.tld/).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AllowPrivatePublicEndpoints && !IsPublicHost(endpoint.Host))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:Deployment:PublicHttpBaseUrl must use a public DNS name or address; set AllowPrivatePublicEndpoints=true only for an isolated deployment smoke test.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateUdpEndpoint(List<string> errors)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PublicUdpHost)
|
||||||
|
|| PublicUdpHost.Contains("//", StringComparison.Ordinal)
|
||||||
|
|| PublicUdpHost.Contains(':', StringComparison.Ordinal) && !IPAddress.TryParse(PublicUdpHost, out _)
|
||||||
|
|| Uri.CheckHostName(PublicUdpHost) == UriHostNameType.Unknown)
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:Deployment:PublicUdpHost must contain only the advertised DNS name or IP address; configure the port separately.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AllowPrivatePublicEndpoints && !IsPublicHost(PublicUdpHost))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"Rendezvous:Deployment:PublicUdpHost must use a public DNS name or address; set AllowPrivatePublicEndpoints=true only for an isolated deployment smoke test.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateAllowedHosts(
|
||||||
|
List<string> errors,
|
||||||
|
string? allowedHosts,
|
||||||
|
string publicHttpBaseUrl)
|
||||||
|
{
|
||||||
|
string[] hosts = (allowedHosts ?? string.Empty).Split(
|
||||||
|
';',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (hosts.Length == 0 || hosts.Any(static host => host is "*" or "+"))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"AllowedHosts must explicitly list the public HTTP host in production; wildcard or empty host filtering is unsafe.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Uri.TryCreate(publicHttpBaseUrl, UriKind.Absolute, out Uri? endpoint)
|
||||||
|
&& !hosts.Contains(endpoint.Host, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
"AllowedHosts must contain the exact host advertised by Rendezvous:Deployment:PublicHttpBaseUrl.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPublicHost(string host)
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(host, out IPAddress? address))
|
||||||
|
{
|
||||||
|
return Uri.CheckHostName(host) == UriHostNameType.Dns
|
||||||
|
&& host.Contains('.', StringComparison.Ordinal)
|
||||||
|
&& !IsReservedDnsName(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsGloballyRoutableUnicast(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsReservedDnsName(string host)
|
||||||
|
{
|
||||||
|
string normalized = host.TrimEnd('.');
|
||||||
|
string[] reservedSuffixes =
|
||||||
|
[
|
||||||
|
"localhost",
|
||||||
|
"local",
|
||||||
|
"invalid",
|
||||||
|
"test",
|
||||||
|
"example",
|
||||||
|
"example.com",
|
||||||
|
"example.net",
|
||||||
|
"example.org",
|
||||||
|
"home.arpa",
|
||||||
|
"alt",
|
||||||
|
"onion",
|
||||||
|
];
|
||||||
|
return reservedSuffixes.Any(suffix =>
|
||||||
|
string.Equals(normalized, suffix, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| normalized.EndsWith($".{suffix}", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGloballyRoutableUnicast(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address.IsIPv4MappedToIPv6)
|
||||||
|
{
|
||||||
|
address = address.MapToIPv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
return !(bytes[0] is 0 or 10 or 127
|
||||||
|
|| bytes[0] == 100 && bytes[1] is >= 64 and <= 127
|
||||||
|
|| bytes[0] == 169 && bytes[1] == 254
|
||||||
|
|| bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|
||||||
|
|| bytes[0] == 192
|
||||||
|
&& (bytes[1] == 0 && bytes[2] is 0 or 2
|
||||||
|
|| bytes[1] == 88 && bytes[2] == 99
|
||||||
|
|| bytes[1] == 168)
|
||||||
|
|| bytes[0] == 198
|
||||||
|
&& (bytes[1] is 18 or 19
|
||||||
|
|| bytes[1] == 51 && bytes[2] == 100)
|
||||||
|
|| bytes[0] == 203 && bytes[1] == 0 && bytes[2] == 113
|
||||||
|
|| bytes[0] >= 224);
|
||||||
|
}
|
||||||
|
|
||||||
|
return address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6
|
||||||
|
&& !IPAddress.IsLoopback(address)
|
||||||
|
&& !address.Equals(IPAddress.IPv6Any)
|
||||||
|
&& !address.IsIPv6LinkLocal
|
||||||
|
&& !address.IsIPv6SiteLocal
|
||||||
|
&& !address.IsIPv6Multicast
|
||||||
|
&& (bytes[0] & 0xe0) == 0x20
|
||||||
|
&& !HasPrefix(bytes, [0x20, 0x01, 0x00], 23)
|
||||||
|
&& !HasPrefix(bytes, [0x20, 0x01, 0x0d, 0xb8], 32)
|
||||||
|
&& !HasPrefix(bytes, [0x20, 0x02], 16)
|
||||||
|
&& !HasPrefix(bytes, [0x3f, 0xff, 0x00], 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasPrefix(byte[] address, byte[] prefix, int bitCount)
|
||||||
|
{
|
||||||
|
int fullBytes = bitCount / 8;
|
||||||
|
for (int index = 0; index < fullBytes; index++)
|
||||||
|
{
|
||||||
|
if (address[index] != prefix[index])
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int remainingBits = bitCount % 8;
|
||||||
|
if (remainingBits == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int mask = 0xff << (8 - remainingBits);
|
||||||
|
return (address[fullBytes] & mask) == (prefix[fullBytes] & mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class DeploymentConfigurationException(IReadOnlyList<string> errors)
|
||||||
|
: InvalidOperationException(
|
||||||
|
"Production deployment configuration is invalid:" + Environment.NewLine
|
||||||
|
+ string.Join(Environment.NewLine, errors.Select(static error => $"- {error}")))
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Deployment;
|
||||||
|
|
||||||
|
internal sealed class GracefulDrainService : IHostedService, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||||
|
private readonly InMemoryEphemeralRendezvousStore _store;
|
||||||
|
private readonly IHostApplicationLifetime _lifetime;
|
||||||
|
private readonly DeploymentOptions _options;
|
||||||
|
private readonly ILogger<GracefulDrainService> _logger;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private CancellationTokenRegistration _stoppingRegistration;
|
||||||
|
private Task? _drainTask;
|
||||||
|
|
||||||
|
public GracefulDrainService(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
IHostApplicationLifetime lifetime,
|
||||||
|
IOptions<DeploymentOptions> options,
|
||||||
|
ILogger<GracefulDrainService> logger)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_lifetime = lifetime;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
_stoppingRegistration = _lifetime.ApplicationStopping.Register(
|
||||||
|
() => EnsureDrainAsync().GetAwaiter().GetResult());
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// ApplicationStopping callbacks run before hosted services and listeners
|
||||||
|
// stop. StopAsync is the idempotent fallback for directly driven hosts.
|
||||||
|
_ = cancellationToken;
|
||||||
|
return EnsureDrainAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _stoppingRegistration.Dispose();
|
||||||
|
|
||||||
|
private Task EnsureDrainAsync()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _drainTask ??= DrainAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DrainAsync()
|
||||||
|
{
|
||||||
|
_store.BeginDrain(CancellationToken.None);
|
||||||
|
TimeSpan deadline = TimeSpan.FromSeconds(_options.DrainDeadlineSeconds);
|
||||||
|
TimeSpan minimum = TimeSpan.FromSeconds(_options.MinimumDrainSeconds);
|
||||||
|
long startedAt = Stopwatch.GetTimestamp();
|
||||||
|
LogDrainStarted(_logger, _options.DrainDeadlineSeconds);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (Stopwatch.GetElapsedTime(startedAt) < deadline)
|
||||||
|
{
|
||||||
|
TimeSpan elapsed = Stopwatch.GetElapsedTime(startedAt);
|
||||||
|
if (elapsed >= minimum && _store.GetActiveJoinAttemptCountForDrain() == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan remaining = deadline - elapsed;
|
||||||
|
await Task.Delay(
|
||||||
|
remaining < PollInterval ? remaining : PollInterval,
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_store.MarkUnavailable();
|
||||||
|
double elapsedMilliseconds = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
||||||
|
LogDrainFinished(_logger, elapsedMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Action<ILogger, int, Exception?> DrainStarted = LoggerMessage.Define<int>(
|
||||||
|
LogLevel.Information,
|
||||||
|
new EventId(1, nameof(LogDrainStarted)),
|
||||||
|
"Graceful drain started with a {DrainDeadlineSeconds}-second deadline");
|
||||||
|
|
||||||
|
private static readonly Action<ILogger, double, Exception?> DrainFinished = LoggerMessage.Define<double>(
|
||||||
|
LogLevel.Information,
|
||||||
|
new EventId(2, nameof(LogDrainFinished)),
|
||||||
|
"Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared");
|
||||||
|
|
||||||
|
private static void LogDrainStarted(ILogger logger, int drainDeadlineSeconds) =>
|
||||||
|
DrainStarted(logger, drainDeadlineSeconds, null);
|
||||||
|
|
||||||
|
private static void LogDrainFinished(ILogger logger, double elapsedMilliseconds) =>
|
||||||
|
DrainFinished(logger, elapsedMilliseconds, null);
|
||||||
|
}
|
||||||
@@ -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,74 @@
|
|||||||
<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="ConnectionOutcomes/ConnectionOutcomeService.cs" />
|
||||||
|
<Compile Include="Deployment/DeploymentOptions.cs" />
|
||||||
|
<Compile Include="Deployment/GracefulDrainService.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/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>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ using Microsoft.AspNetCore.Diagnostics;
|
|||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Server.Http;
|
namespace FinalFactory.Rendezvous.Server.Http;
|
||||||
|
|
||||||
internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
internal sealed class RendezvousExceptionHandler(
|
||||||
|
ILogger<RendezvousExceptionHandler> logger) : IExceptionHandler
|
||||||
{
|
{
|
||||||
public async ValueTask<bool> TryHandleAsync(
|
public async ValueTask<bool> TryHandleAsync(
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
@@ -26,6 +27,13 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
|||||||
: invalidRequest
|
: invalidRequest
|
||||||
? StatusCodes.Status400BadRequest
|
? StatusCodes.Status400BadRequest
|
||||||
: StatusCodes.Status500InternalServerError;
|
: StatusCodes.Status500InternalServerError;
|
||||||
|
LogRequestFailure(
|
||||||
|
logger,
|
||||||
|
payloadTooLarge ? "payload-too-large" : invalidRequest ? "invalid-request" : "internal-error",
|
||||||
|
httpContext.Response.StatusCode,
|
||||||
|
httpContext.Response.Headers["X-Rendezvous-Correlation-ID"].ToString() is { Length: > 0 } value
|
||||||
|
? value
|
||||||
|
: "unavailable");
|
||||||
await httpContext.Response.WriteAsJsonAsync(
|
await httpContext.Response.WriteAsJsonAsync(
|
||||||
new ApiError
|
new ApiError
|
||||||
{
|
{
|
||||||
@@ -42,4 +50,16 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
|||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly Action<ILogger, string, int, string, Exception?> RequestFailure =
|
||||||
|
LoggerMessage.Define<string, int, string>(
|
||||||
|
LogLevel.Warning,
|
||||||
|
new EventId(200, nameof(LogRequestFailure)),
|
||||||
|
"Request failed with {FailureKind} and HTTP status {StatusCode}; correlation {CorrelationId}");
|
||||||
|
|
||||||
|
private static void LogRequestFailure(
|
||||||
|
ILogger logger,
|
||||||
|
string failureKind,
|
||||||
|
int statusCode,
|
||||||
|
string correlationId) => RequestFailure(logger, failureKind, statusCode, correlationId, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed class AuditOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Rendezvous:Audit";
|
||||||
|
|
||||||
|
[Range(100, 100_000)]
|
||||||
|
public int MaxEntries { get; set; } = 10_000;
|
||||||
|
|
||||||
|
[Range(1, 30)]
|
||||||
|
public int RetentionDays { get; set; } = 30;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed class AuditTrail
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly LinkedList<AuditEntry> _entries = [];
|
||||||
|
private readonly AuditOptions _options;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<AuditTrail> _logger;
|
||||||
|
private readonly RendezvousTelemetry _telemetry;
|
||||||
|
|
||||||
|
public AuditTrail(
|
||||||
|
IOptions<AuditOptions> options,
|
||||||
|
ILogger<AuditTrail> logger,
|
||||||
|
RendezvousTelemetry telemetry,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
_telemetry = telemetry;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Record(
|
||||||
|
string actorSubject,
|
||||||
|
string action,
|
||||||
|
string result,
|
||||||
|
string targetKind,
|
||||||
|
string targetIdentifier,
|
||||||
|
string correlationId)
|
||||||
|
{
|
||||||
|
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||||
|
AuditEntry entry = new(
|
||||||
|
now,
|
||||||
|
Fingerprint(actorSubject),
|
||||||
|
action,
|
||||||
|
result,
|
||||||
|
targetKind,
|
||||||
|
Fingerprint(targetIdentifier),
|
||||||
|
correlationId);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
PurgeExpired(now);
|
||||||
|
|
||||||
|
while (_entries.Count >= _options.MaxEntries)
|
||||||
|
{
|
||||||
|
_entries.RemoveFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
_entries.AddLast(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
_telemetry.RecordAudit(action, result);
|
||||||
|
LogOperatorAction(
|
||||||
|
_logger,
|
||||||
|
entry.ActorFingerprint,
|
||||||
|
action,
|
||||||
|
result,
|
||||||
|
targetKind,
|
||||||
|
entry.TargetFingerprint,
|
||||||
|
correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, long> GetAggregateCounts()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
PurgeExpired(_timeProvider.GetUtcNow());
|
||||||
|
return _entries
|
||||||
|
.GroupBy(static entry => $"{entry.Action}:{entry.Result}", StringComparer.Ordinal)
|
||||||
|
.ToDictionary(
|
||||||
|
static group => group.Key,
|
||||||
|
static group => (long)group.Count(),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IReadOnlyList<AuditEntry> GetEntriesForTests()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
PurgeExpired(_timeProvider.GetUtcNow());
|
||||||
|
return _entries.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PurgeExpired(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
DateTimeOffset oldest = now.AddDays(-_options.RetentionDays);
|
||||||
|
while (_entries.First is { Value.Timestamp: var timestamp }
|
||||||
|
&& timestamp < oldest)
|
||||||
|
{
|
||||||
|
_entries.RemoveFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Fingerprint(string value)
|
||||||
|
{
|
||||||
|
byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||||
|
return Convert.ToHexString(digest.AsSpan(0, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Action<ILogger, string, string, string, string, string, string, Exception?>
|
||||||
|
OperatorAction = LoggerMessage.Define<string, string, string, string, string, string>(
|
||||||
|
LogLevel.Information,
|
||||||
|
new EventId(100, nameof(LogOperatorAction)),
|
||||||
|
"Operator audit: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}");
|
||||||
|
|
||||||
|
private static void LogOperatorAction(
|
||||||
|
ILogger logger,
|
||||||
|
string actorFingerprint,
|
||||||
|
string action,
|
||||||
|
string result,
|
||||||
|
string targetKind,
|
||||||
|
string targetFingerprint,
|
||||||
|
string correlationId) => OperatorAction(
|
||||||
|
logger,
|
||||||
|
actorFingerprint,
|
||||||
|
action,
|
||||||
|
result,
|
||||||
|
targetKind,
|
||||||
|
targetFingerprint,
|
||||||
|
correlationId,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record AuditEntry(
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
string ActorFingerprint,
|
||||||
|
string Action,
|
||||||
|
string Result,
|
||||||
|
string TargetKind,
|
||||||
|
string TargetFingerprint,
|
||||||
|
string CorrelationId)
|
||||||
|
{
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[AuditEntry {Action}/{Result}; actor and target fingerprinted]";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal static class HealthEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapRendezvousHealthEndpoints(
|
||||||
|
this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
endpoints.MapGet(
|
||||||
|
"/health/live",
|
||||||
|
static () => Results.Ok(new HealthResponse { Status = "live" }))
|
||||||
|
.Produces<HealthResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.WithName("GetLiveness")
|
||||||
|
.WithTags("Health");
|
||||||
|
endpoints.MapGet(
|
||||||
|
"/health/ready",
|
||||||
|
static (RendezvousReadiness readiness) =>
|
||||||
|
!readiness.GetSnapshot().IsReady
|
||||||
|
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
: Results.Ok(new HealthResponse { Status = "ready" }))
|
||||||
|
.Produces<HealthResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("GetReadiness")
|
||||||
|
.WithTags("Health");
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed class RendezvousReadiness(
|
||||||
|
UdpMediatorService mediator,
|
||||||
|
ProvisioningReadiness provisioning,
|
||||||
|
IEphemeralRendezvousStore state,
|
||||||
|
IOptions<UdpMediatorOptions> udpOptions)
|
||||||
|
{
|
||||||
|
public ReadinessSnapshot GetSnapshot()
|
||||||
|
{
|
||||||
|
bool ipv6Required = !string.IsNullOrWhiteSpace(udpOptions.Value.Ipv6ListenAddress);
|
||||||
|
return new ReadinessSnapshot(
|
||||||
|
HttpListenerReady: true,
|
||||||
|
UdpIpv4ListenerReady: mediator.LocalEndpoint is not null,
|
||||||
|
UdpIpv6ListenerReady: !ipv6Required || mediator.LocalIpv6Endpoint is not null,
|
||||||
|
ProvisioningReady: provisioning.IsReady,
|
||||||
|
StoreAvailable: state.IsAvailable,
|
||||||
|
Draining: state.IsDraining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ReadinessSnapshot(
|
||||||
|
bool HttpListenerReady,
|
||||||
|
bool UdpIpv4ListenerReady,
|
||||||
|
bool UdpIpv6ListenerReady,
|
||||||
|
bool ProvisioningReady,
|
||||||
|
bool StoreAvailable,
|
||||||
|
bool Draining)
|
||||||
|
{
|
||||||
|
public bool IsReady => HttpListenerReady
|
||||||
|
&& UdpIpv4ListenerReady
|
||||||
|
&& UdpIpv6ListenerReady
|
||||||
|
&& ProvisioningReady
|
||||||
|
&& StoreAvailable
|
||||||
|
&& !Draining;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed class RendezvousTelemetry : IDisposable
|
||||||
|
{
|
||||||
|
public const string MeterName = "FinalFactory.Rendezvous";
|
||||||
|
public const string ActivitySourceName = "FinalFactory.Rendezvous.Server";
|
||||||
|
|
||||||
|
private readonly InMemoryEphemeralRendezvousStore _store;
|
||||||
|
private readonly Meter _meter = new(MeterName, "1.0.0");
|
||||||
|
private readonly ActivitySource _activities = new(ActivitySourceName, "1.0.0");
|
||||||
|
private readonly Counter<long> _httpRequests;
|
||||||
|
private readonly Histogram<double> _httpDuration;
|
||||||
|
private readonly Counter<long> _udpResults;
|
||||||
|
private readonly Histogram<double> _udpDuration;
|
||||||
|
private readonly Counter<long> _limiterDrops;
|
||||||
|
private readonly Counter<long> _auditEvents;
|
||||||
|
private readonly Counter<long> _connectionOutcomes;
|
||||||
|
private readonly Counter<long> _operatorAuthentication;
|
||||||
|
private readonly Histogram<double> _pairingLatency;
|
||||||
|
|
||||||
|
public RendezvousTelemetry(InMemoryEphemeralRendezvousStore store)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_httpRequests = _meter.CreateCounter<long>("rendezvous.http.requests");
|
||||||
|
_httpDuration = _meter.CreateHistogram<double>(
|
||||||
|
"rendezvous.http.duration",
|
||||||
|
"ms");
|
||||||
|
_udpResults = _meter.CreateCounter<long>("rendezvous.udp.results");
|
||||||
|
_udpDuration = _meter.CreateHistogram<double>(
|
||||||
|
"rendezvous.udp.duration",
|
||||||
|
"ms");
|
||||||
|
_limiterDrops = _meter.CreateCounter<long>("rendezvous.limiter.drops");
|
||||||
|
_auditEvents = _meter.CreateCounter<long>("rendezvous.audit.events");
|
||||||
|
_connectionOutcomes = _meter.CreateCounter<long>("rendezvous.connection.outcomes");
|
||||||
|
_operatorAuthentication = _meter.CreateCounter<long>("rendezvous.operator.authentication");
|
||||||
|
_pairingLatency = _meter.CreateHistogram<double>(
|
||||||
|
"rendezvous.pairing.latency",
|
||||||
|
"ms");
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.active_listings",
|
||||||
|
() => _store.GetMetricsSnapshot().ActiveListings);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.active_leases",
|
||||||
|
() => _store.GetMetricsSnapshot().ActiveListings);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.active_attempts",
|
||||||
|
() => _store.GetMetricsSnapshot().ActiveJoinAttempts);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.queue.depth",
|
||||||
|
() => _store.GetMetricsSnapshot().ActiveJoinAttempts);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.replay_markers",
|
||||||
|
() => _store.GetMetricsSnapshot().ReplayMarkers);
|
||||||
|
_meter.CreateObservableGauge(
|
||||||
|
"rendezvous.store.available",
|
||||||
|
() => _store.GetMetricsSnapshot().IsAvailable ? 1 : 0);
|
||||||
|
_meter.CreateObservableCounter(
|
||||||
|
"rendezvous.store.expiry_churn",
|
||||||
|
() => _store.GetMetricsSnapshot().ExpiryChurn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) =>
|
||||||
|
_activities.StartActivity(name, kind);
|
||||||
|
|
||||||
|
public void RecordHttp(string operation, int statusCode, double elapsedMilliseconds)
|
||||||
|
{
|
||||||
|
TagList tags = new()
|
||||||
|
{
|
||||||
|
{ "operation", operation },
|
||||||
|
{ "status_code", statusCode },
|
||||||
|
};
|
||||||
|
_httpRequests.Add(1, tags);
|
||||||
|
_httpDuration.Record(elapsedMilliseconds, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordUdp(string operation, string result, double elapsedMilliseconds)
|
||||||
|
{
|
||||||
|
TagList tags = new()
|
||||||
|
{
|
||||||
|
{ "operation", operation },
|
||||||
|
{ "result", result },
|
||||||
|
};
|
||||||
|
_udpResults.Add(1, tags);
|
||||||
|
_udpDuration.Record(elapsedMilliseconds, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordLimiterDrop(string transport, string partition) =>
|
||||||
|
_limiterDrops.Add(1, new TagList
|
||||||
|
{
|
||||||
|
{ "transport", transport },
|
||||||
|
{ "partition", partition },
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordAudit(string action, string result) =>
|
||||||
|
_auditEvents.Add(1, new TagList
|
||||||
|
{
|
||||||
|
{ "action", action },
|
||||||
|
{ "result", result },
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordConnectionOutcome(string outcome, string elapsedBucket) =>
|
||||||
|
_connectionOutcomes.Add(1, new TagList
|
||||||
|
{
|
||||||
|
{ "outcome", outcome },
|
||||||
|
{ "elapsed_bucket", elapsedBucket },
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordOperatorAuthentication(string result) =>
|
||||||
|
_operatorAuthentication.Add(1, new TagList
|
||||||
|
{
|
||||||
|
{ "result", result },
|
||||||
|
});
|
||||||
|
|
||||||
|
public void RecordPairingLatency(double elapsedMilliseconds) =>
|
||||||
|
_pairingLatency.Record(elapsedMilliseconds);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_activities.Dispose();
|
||||||
|
_meter.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
|
||||||
|
internal sealed class TelemetryMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
RendezvousTelemetry telemetry)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
string operation = context.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()
|
||||||
|
?.EndpointName ?? "Unmatched";
|
||||||
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
using Activity? activity = telemetry.StartActivity(
|
||||||
|
$"HTTP {operation}",
|
||||||
|
ActivityKind.Server);
|
||||||
|
string correlationId = activity?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
|
||||||
|
context.Response.Headers["X-Rendezvous-Correlation-ID"] = correlationId;
|
||||||
|
activity?.SetTag("rendezvous.operation", operation);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await next(context).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
telemetry.RecordHttp(
|
||||||
|
operation,
|
||||||
|
context.Response.StatusCode,
|
||||||
|
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Operations;
|
||||||
|
|
||||||
|
internal static class OperatorEndpoints
|
||||||
|
{
|
||||||
|
private const string CorrelationHeader = "X-Rendezvous-Correlation-ID";
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapOperatorEndpoints(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
RouteGroupBuilder group = endpoints.MapGroup("/v1/operator").WithTags("Operator");
|
||||||
|
group.MapGet("/status", GetStatus)
|
||||||
|
.Produces<OperatorStatusResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.WithName("GetOperatorStatus");
|
||||||
|
group.MapPost("/listings/revoke", RevokeListing)
|
||||||
|
.Accepts<RevokeListingRequest>("application/json")
|
||||||
|
.Produces<OperatorActionResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("RevokeOperatorListing");
|
||||||
|
group.MapPost("/principals/revoke", RevokePrincipal)
|
||||||
|
.Accepts<RevokePrincipalRequest>("application/json")
|
||||||
|
.Produces<OperatorActionResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||||
|
.WithName("RevokeOperatorPrincipal");
|
||||||
|
group.MapPost("/keys/revoke", RevokeSigningKey)
|
||||||
|
.Accepts<RevokeSigningKeyRequest>("application/json")
|
||||||
|
.Produces<OperatorActionResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.WithName("RevokeOperatorSigningKey");
|
||||||
|
group.MapPost("/drain", BeginDrain)
|
||||||
|
.Accepts<BeginDrainRequest>("application/json")
|
||||||
|
.Produces<OperatorActionResponse>()
|
||||||
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||||
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||||
|
.WithName("BeginOperatorDrain");
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult GetStatus(
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.ReadPolicy,
|
||||||
|
"inspect-status",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperatorStatusResponse response = service.GetStatus();
|
||||||
|
audit.Record(
|
||||||
|
principal!.Subject,
|
||||||
|
"inspect-status",
|
||||||
|
"succeeded",
|
||||||
|
"service",
|
||||||
|
"rendezvous",
|
||||||
|
Correlation(context));
|
||||||
|
return Results.Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RevokeListing(
|
||||||
|
[FromBody] RevokeListingRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.RevokePublisher,
|
||||||
|
"revoke-listing",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool valid = SessionListingId.TryParse(request.ListingId, out SessionListingId listingId);
|
||||||
|
if (!valid || !string.Equals(request.ListingId, request.ConfirmListingId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
AuditRejected(audit, principal!, "revoke-listing", "listing", request.ListingId, context);
|
||||||
|
return BadRequest("A valid listing ID and an exact repeated confirmation are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<bool> result = service.RevokeListing(listingId, cancellationToken);
|
||||||
|
return StoreActionResult(
|
||||||
|
result.Code,
|
||||||
|
audit,
|
||||||
|
principal!,
|
||||||
|
"revoke-listing",
|
||||||
|
"listing",
|
||||||
|
request.ListingId,
|
||||||
|
context,
|
||||||
|
affectedResources: result.Succeeded ? 1 : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RevokePrincipal(
|
||||||
|
[FromBody] RevokePrincipalRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.RevokePublisher,
|
||||||
|
"revoke-principal",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool safeSubject = request.Subject is { Length: > 0 and <= 128 }
|
||||||
|
&& request.Subject.All(static character => character is >= '!' and <= '~');
|
||||||
|
if (!safeSubject
|
||||||
|
|| !string.Equals(request.Subject, request.ConfirmSubject, StringComparison.Ordinal)
|
||||||
|
|| request.LifetimeSeconds is < 1 or > 600)
|
||||||
|
{
|
||||||
|
AuditRejected(audit, principal!, "revoke-principal", "principal", request.Subject, context);
|
||||||
|
return BadRequest("A valid subject, exact repeated confirmation, and 1-600 second lifetime are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreResult<int> result = service.RevokePrincipal(
|
||||||
|
request.Subject,
|
||||||
|
TimeSpan.FromSeconds(request.LifetimeSeconds),
|
||||||
|
cancellationToken);
|
||||||
|
return StoreActionResult(
|
||||||
|
result.Code,
|
||||||
|
audit,
|
||||||
|
principal!,
|
||||||
|
"revoke-principal",
|
||||||
|
"principal",
|
||||||
|
request.Subject,
|
||||||
|
context,
|
||||||
|
result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RevokeSigningKey(
|
||||||
|
[FromBody] RevokeSigningKeyRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.RotateKeys,
|
||||||
|
"revoke-signing-key",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool safeKeyId = request.KeyId is { Length: > 0 and <= 64 }
|
||||||
|
&& request.KeyId.All(static character => character is
|
||||||
|
>= 'A' and <= 'Z'
|
||||||
|
or >= 'a' and <= 'z'
|
||||||
|
or >= '0' and <= '9'
|
||||||
|
or '-'
|
||||||
|
or '_');
|
||||||
|
if (!safeKeyId || !string.Equals(request.KeyId, request.ConfirmKeyId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
AuditRejected(audit, principal!, "revoke-signing-key", "signing-key", request.KeyId, context);
|
||||||
|
return BadRequest("A valid key ID and an exact repeated confirmation are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool revoked = service.RevokeSigningKey(request.KeyId);
|
||||||
|
string result = revoked ? "succeeded" : "not-found";
|
||||||
|
audit.Record(
|
||||||
|
principal!.Subject,
|
||||||
|
"revoke-signing-key",
|
||||||
|
result,
|
||||||
|
"signing-key",
|
||||||
|
request.KeyId,
|
||||||
|
Correlation(context));
|
||||||
|
return revoked
|
||||||
|
? Results.Ok(new OperatorActionResponse { Status = "completed" })
|
||||||
|
: Error(RendezvousErrorCode.NotFound, "The requested resource was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult BeginDrain(
|
||||||
|
[FromBody] BeginDrainRequest request,
|
||||||
|
[FromHeader(Name = "Authorization")] string? authorization,
|
||||||
|
[FromServices] PrincipalCredentialService credentials,
|
||||||
|
[FromServices] IWallClock clock,
|
||||||
|
[FromServices] OperatorService service,
|
||||||
|
[FromServices] AuditTrail audit,
|
||||||
|
[FromServices] RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryAuthorize(
|
||||||
|
authorization,
|
||||||
|
OperatorPermission.ManagePolicy,
|
||||||
|
"begin-drain",
|
||||||
|
credentials,
|
||||||
|
clock,
|
||||||
|
audit,
|
||||||
|
telemetry,
|
||||||
|
context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure))
|
||||||
|
{
|
||||||
|
return failure!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(request.Confirmation, "DRAIN", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
AuditRejected(audit, principal!, "begin-drain", "service", "rendezvous", context);
|
||||||
|
return BadRequest("The confirmation value must be exactly 'DRAIN'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
service.BeginDrain(cancellationToken);
|
||||||
|
audit.Record(
|
||||||
|
principal!.Subject,
|
||||||
|
"begin-drain",
|
||||||
|
"succeeded",
|
||||||
|
"service",
|
||||||
|
"rendezvous",
|
||||||
|
Correlation(context));
|
||||||
|
return Results.Ok(new OperatorActionResponse { Status = "draining" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryAuthorize(
|
||||||
|
string? authorization,
|
||||||
|
OperatorPermission requiredPermission,
|
||||||
|
string operation,
|
||||||
|
PrincipalCredentialService credentials,
|
||||||
|
IWallClock clock,
|
||||||
|
AuditTrail audit,
|
||||||
|
RendezvousTelemetry telemetry,
|
||||||
|
HttpContext context,
|
||||||
|
out OperatorPrincipal? principal,
|
||||||
|
out IResult? failure)
|
||||||
|
{
|
||||||
|
principal = null;
|
||||||
|
failure = null;
|
||||||
|
const string prefix = "Bearer ";
|
||||||
|
if (authorization is null
|
||||||
|
|| !authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
telemetry.RecordOperatorAuthentication("rejected");
|
||||||
|
failure = AuthenticationRequired(context);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CredentialValidationResult validation = credentials.Validate(
|
||||||
|
authorization[prefix.Length..],
|
||||||
|
clock.UtcNow);
|
||||||
|
if (!validation.IsValid || validation.Principal is not OperatorPrincipal candidate)
|
||||||
|
{
|
||||||
|
telemetry.RecordOperatorAuthentication("rejected");
|
||||||
|
failure = AuthenticationRequired(context);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidate.Permissions.Contains(requiredPermission))
|
||||||
|
{
|
||||||
|
telemetry.RecordOperatorAuthentication("forbidden");
|
||||||
|
audit.Record(
|
||||||
|
candidate.Subject,
|
||||||
|
operation,
|
||||||
|
"forbidden",
|
||||||
|
"operator-operation",
|
||||||
|
operation,
|
||||||
|
Correlation(context));
|
||||||
|
failure = Error(RendezvousErrorCode.Forbidden, "The operator is not authorized for this operation.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
telemetry.RecordOperatorAuthentication("accepted");
|
||||||
|
principal = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult StoreActionResult(
|
||||||
|
StoreResultCode code,
|
||||||
|
AuditTrail audit,
|
||||||
|
OperatorPrincipal principal,
|
||||||
|
string action,
|
||||||
|
string targetKind,
|
||||||
|
string targetIdentifier,
|
||||||
|
HttpContext context,
|
||||||
|
int? affectedResources)
|
||||||
|
{
|
||||||
|
string auditResult = code == StoreResultCode.Success
|
||||||
|
? "succeeded"
|
||||||
|
: code.ToString().ToLowerInvariant();
|
||||||
|
audit.Record(
|
||||||
|
principal.Subject,
|
||||||
|
action,
|
||||||
|
auditResult,
|
||||||
|
targetKind,
|
||||||
|
targetIdentifier,
|
||||||
|
Correlation(context));
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
StoreResultCode.Success => Results.Ok(new OperatorActionResponse
|
||||||
|
{
|
||||||
|
Status = "completed",
|
||||||
|
AffectedResources = affectedResources,
|
||||||
|
}),
|
||||||
|
StoreResultCode.NotFound => Error(
|
||||||
|
RendezvousErrorCode.NotFound,
|
||||||
|
"The requested resource was not found."),
|
||||||
|
StoreResultCode.CapacityExceeded => Error(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
"The operation could not be retained within the configured capacity."),
|
||||||
|
StoreResultCode.ServiceUnavailable or StoreResultCode.Draining => Error(
|
||||||
|
RendezvousErrorCode.ServiceUnavailable,
|
||||||
|
"The service is not available for this operation."),
|
||||||
|
_ => Error(RendezvousErrorCode.Conflict, "The operation could not be completed."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AuditRejected(
|
||||||
|
AuditTrail audit,
|
||||||
|
OperatorPrincipal principal,
|
||||||
|
string action,
|
||||||
|
string targetKind,
|
||||||
|
string? targetIdentifier,
|
||||||
|
HttpContext context) => audit.Record(
|
||||||
|
principal.Subject,
|
||||||
|
action,
|
||||||
|
"rejected",
|
||||||
|
targetKind,
|
||||||
|
targetIdentifier ?? string.Empty,
|
||||||
|
Correlation(context));
|
||||||
|
|
||||||
|
private static string Correlation(HttpContext context) =>
|
||||||
|
context.Response.Headers[CorrelationHeader].ToString() is { Length: > 0 } value
|
||||||
|
? value
|
||||||
|
: "unavailable";
|
||||||
|
|
||||||
|
private static IResult AuthenticationRequired(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"operator\"";
|
||||||
|
return Error(
|
||||||
|
RendezvousErrorCode.AuthenticationRequired,
|
||||||
|
"A valid operator bearer credential is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult BadRequest(string message) => Error(RendezvousErrorCode.InvalidRequest, message);
|
||||||
|
|
||||||
|
private static IResult Error(RendezvousErrorCode code, string message) => Results.Json(
|
||||||
|
new ApiError { Code = code, Message = message },
|
||||||
|
ContractJson.Options,
|
||||||
|
statusCode: code switch
|
||||||
|
{
|
||||||
|
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
|
||||||
|
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
|
||||||
|
RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound,
|
||||||
|
RendezvousErrorCode.CapacityExceeded => StatusCodes.Status429TooManyRequests,
|
||||||
|
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
|
||||||
|
RendezvousErrorCode.Conflict => StatusCodes.Status409Conflict,
|
||||||
|
_ => StatusCodes.Status400BadRequest,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.Server.Operations;
|
||||||
|
|
||||||
|
internal sealed record OperatorStatusResponse
|
||||||
|
{
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public required OperatorCompatibilityResponse Compatibility { get; init; }
|
||||||
|
public required OperatorReadinessResponse Readiness { get; init; }
|
||||||
|
public required OperatorStoreResponse Store { get; init; }
|
||||||
|
public required IReadOnlyList<OperatorTenantResponse> Tenants { get; init; }
|
||||||
|
public required IReadOnlyList<OperatorSigningKeyResponse> SigningKeys { get; init; }
|
||||||
|
public required IReadOnlyDictionary<string, long> AuditCounts { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record 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
|
||||||
|
{
|
||||||
|
public required bool HttpListener { get; init; }
|
||||||
|
public required bool UdpIpv4Listener { get; init; }
|
||||||
|
public required bool UdpIpv6Listener { get; init; }
|
||||||
|
public required bool Provisioning { get; init; }
|
||||||
|
public required bool Store { get; init; }
|
||||||
|
public required bool Draining { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorStoreResponse
|
||||||
|
{
|
||||||
|
public required int ActiveListings { get; init; }
|
||||||
|
public required int FreshPresenceBindings { get; init; }
|
||||||
|
public required int ActiveJoinAttempts { get; init; }
|
||||||
|
public required int RetainedOutcomeReports { get; init; }
|
||||||
|
public required int ReplayMarkers { get; init; }
|
||||||
|
public required int PrincipalRevocations { get; init; }
|
||||||
|
public required int IdempotencyEntries { get; init; }
|
||||||
|
public required long MaintenanceSweeps { get; init; }
|
||||||
|
public required long ExpiryChurn { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorTenantResponse
|
||||||
|
{
|
||||||
|
public required string GameId { get; init; }
|
||||||
|
public required string EnvironmentId { get; init; }
|
||||||
|
public required string Status { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorSigningKeyResponse
|
||||||
|
{
|
||||||
|
public required string KeyId { get; init; }
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public required DateTimeOffset SignUntil { get; init; }
|
||||||
|
public required DateTimeOffset VerifyUntil { get; init; }
|
||||||
|
public string? GameId { get; init; }
|
||||||
|
public string? EnvironmentId { get; init; }
|
||||||
|
public required IReadOnlyList<string> CredentialKinds { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OperatorActionResponse
|
||||||
|
{
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public int? AffectedResources { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RevokeListingRequest
|
||||||
|
{
|
||||||
|
public required string ListingId { get; init; }
|
||||||
|
public required string ConfirmListingId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RevokePrincipalRequest
|
||||||
|
{
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
public required string ConfirmSubject { get; init; }
|
||||||
|
public required int LifetimeSeconds { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RevokeSigningKeyRequest
|
||||||
|
{
|
||||||
|
public required string KeyId { get; init; }
|
||||||
|
public required string ConfirmKeyId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record BeginDrainRequest
|
||||||
|
{
|
||||||
|
public required string Confirmation { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Server.Operations;
|
||||||
|
|
||||||
|
internal sealed class OperatorService(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
ProvisioningRuntime provisioning,
|
||||||
|
RendezvousReadiness readiness,
|
||||||
|
AuditTrail audit,
|
||||||
|
IWallClock clock)
|
||||||
|
{
|
||||||
|
public OperatorStatusResponse GetStatus()
|
||||||
|
{
|
||||||
|
ReadinessSnapshot readinessSnapshot = readiness.GetSnapshot();
|
||||||
|
EphemeralStoreSnapshot storeSnapshot = store.GetSnapshot();
|
||||||
|
return new OperatorStatusResponse
|
||||||
|
{
|
||||||
|
Status = readinessSnapshot.IsReady ? "ready" : "not-ready",
|
||||||
|
Compatibility = ReleaseCompatibility.CreateResponse(),
|
||||||
|
Readiness = new OperatorReadinessResponse
|
||||||
|
{
|
||||||
|
HttpListener = readinessSnapshot.HttpListenerReady,
|
||||||
|
UdpIpv4Listener = readinessSnapshot.UdpIpv4ListenerReady,
|
||||||
|
UdpIpv6Listener = readinessSnapshot.UdpIpv6ListenerReady,
|
||||||
|
Provisioning = readinessSnapshot.ProvisioningReady,
|
||||||
|
Store = readinessSnapshot.StoreAvailable,
|
||||||
|
Draining = readinessSnapshot.Draining,
|
||||||
|
},
|
||||||
|
Store = new OperatorStoreResponse
|
||||||
|
{
|
||||||
|
ActiveListings = storeSnapshot.ActiveListings,
|
||||||
|
FreshPresenceBindings = storeSnapshot.FreshPresenceBindings,
|
||||||
|
ActiveJoinAttempts = storeSnapshot.ActiveJoinAttempts,
|
||||||
|
RetainedOutcomeReports = storeSnapshot.RetainedOutcomeReports,
|
||||||
|
ReplayMarkers = storeSnapshot.ReplayMarkers,
|
||||||
|
PrincipalRevocations = storeSnapshot.PrincipalRevocations,
|
||||||
|
IdempotencyEntries = storeSnapshot.IdempotencyEntries,
|
||||||
|
MaintenanceSweeps = storeSnapshot.MaintenanceSweeps,
|
||||||
|
ExpiryChurn = storeSnapshot.ExpiryChurn,
|
||||||
|
},
|
||||||
|
Tenants = provisioning.Policies.EnabledPolicies
|
||||||
|
.OrderBy(static policy => policy.GameId.Value, StringComparer.Ordinal)
|
||||||
|
.ThenBy(static policy => policy.EnvironmentId.Value, StringComparer.Ordinal)
|
||||||
|
.Select(static policy => new OperatorTenantResponse
|
||||||
|
{
|
||||||
|
GameId = policy.GameId.Value,
|
||||||
|
EnvironmentId = policy.EnvironmentId.Value,
|
||||||
|
Status = "enabled",
|
||||||
|
})
|
||||||
|
.ToArray(),
|
||||||
|
SigningKeys = provisioning.SigningKeys.GetStatuses(clock.UtcNow)
|
||||||
|
.Select(static key => new OperatorSigningKeyResponse
|
||||||
|
{
|
||||||
|
KeyId = key.KeyId,
|
||||||
|
Status = key.Status,
|
||||||
|
SignUntil = key.SignUntil,
|
||||||
|
VerifyUntil = key.VerifyUntil,
|
||||||
|
GameId = key.GameId,
|
||||||
|
EnvironmentId = key.EnvironmentId,
|
||||||
|
CredentialKinds = key.CredentialKinds,
|
||||||
|
})
|
||||||
|
.ToArray(),
|
||||||
|
AuditCounts = audit.GetAggregateCounts(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoreResult<bool> RevokeListing(
|
||||||
|
SessionListingId listingId,
|
||||||
|
CancellationToken cancellationToken) => store.RevokeListing(listingId, cancellationToken);
|
||||||
|
|
||||||
|
public StoreResult<int> RevokePrincipal(
|
||||||
|
string subject,
|
||||||
|
TimeSpan lifetime,
|
||||||
|
CancellationToken cancellationToken) => store.RevokePrincipal(subject, lifetime, cancellationToken);
|
||||||
|
|
||||||
|
public bool RevokeSigningKey(string keyId) => provisioning.SigningKeys.Revoke(keyId);
|
||||||
|
|
||||||
|
public void BeginDrain(CancellationToken cancellationToken) => store.BeginDrain(cancellationToken);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ using FinalFactory.Rendezvous.Contracts;
|
|||||||
using FinalFactory.Rendezvous.Server.Abuse;
|
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.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.Operations;
|
||||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
using FinalFactory.Rendezvous.Server.Sessions;
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
using FinalFactory.Rendezvous.Server.State;
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
@@ -13,6 +16,9 @@ using Microsoft.AspNetCore.HttpOverrides;
|
|||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.Logging.AddFilter(
|
||||||
|
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
|
||||||
|
LogLevel.None);
|
||||||
bool isOpenApiGeneration = string.Equals(
|
bool isOpenApiGeneration = string.Equals(
|
||||||
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
|
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
|
||||||
"GetDocument.Insider",
|
"GetDocument.Insider",
|
||||||
@@ -61,6 +67,14 @@ builder.Services.AddOpenApi("v1", static options =>
|
|||||||
In = ParameterLocation.Header,
|
In = ParameterLocation.Header,
|
||||||
Description = "Attempt-scoped client capability returned only to the joining caller.",
|
Description = "Attempt-scoped client capability returned only to the joining caller.",
|
||||||
};
|
};
|
||||||
|
const string operatorSchemeName = "OperatorBearer";
|
||||||
|
document.Components.SecuritySchemes[operatorSchemeName] = new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "bearer",
|
||||||
|
BearerFormat = "rv1 operator credential",
|
||||||
|
Description = "Operator-only credential with an explicit permission set.",
|
||||||
|
};
|
||||||
|
|
||||||
HashSet<string> securedOperations = new(StringComparer.Ordinal)
|
HashSet<string> securedOperations = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
@@ -69,8 +83,17 @@ builder.Services.AddOpenApi("v1", static options =>
|
|||||||
"UpdateSession",
|
"UpdateSession",
|
||||||
"DeleteSession",
|
"DeleteSession",
|
||||||
};
|
};
|
||||||
|
HashSet<string> operatorOperations = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"GetOperatorStatus",
|
||||||
|
"RevokeOperatorListing",
|
||||||
|
"RevokeOperatorPrincipal",
|
||||||
|
"RevokeOperatorSigningKey",
|
||||||
|
"BeginOperatorDrain",
|
||||||
|
};
|
||||||
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
|
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
|
||||||
OpenApiSecuritySchemeReference attemptReference = new(attemptSchemeName, document, null);
|
OpenApiSecuritySchemeReference attemptReference = new(attemptSchemeName, document, null);
|
||||||
|
OpenApiSecuritySchemeReference operatorReference = new(operatorSchemeName, document, null);
|
||||||
foreach (OpenApiPathItem path in document.Paths.Values)
|
foreach (OpenApiPathItem path in document.Paths.Values)
|
||||||
{
|
{
|
||||||
if (path.Operations is null)
|
if (path.Operations is null)
|
||||||
@@ -99,29 +122,58 @@ builder.Services.AddOpenApi("v1", static options =>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
||||||
|
operation => operatorOperations.Contains(
|
||||||
|
operation.OperationId ?? string.Empty)))
|
||||||
|
{
|
||||||
|
operation.Security ??= [];
|
||||||
|
operation.Security.Add(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
[operatorReference] = [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
foreach (OpenApiOperation operation in path.Operations.Values)
|
foreach (OpenApiOperation operation in path.Operations.Values)
|
||||||
{
|
{
|
||||||
if (operation.Responses is null
|
if (operation.Responses is null)
|
||||||
|| !operation.Responses.TryGetValue(
|
|
||||||
StatusCodes.Status429TooManyRequests.ToString(
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
out IOpenApiResponse? response)
|
|
||||||
|| response is not OpenApiResponse concreteResponse)
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
concreteResponse.Headers ??=
|
foreach ((string status, IOpenApiResponse response) in operation.Responses)
|
||||||
new Dictionary<string, IOpenApiHeader>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
concreteResponse.Headers["Retry-After"] = new OpenApiHeader
|
|
||||||
{
|
{
|
||||||
Description = "Whole seconds before the caller should retry (1-60).",
|
if (response is not OpenApiResponse concreteResponse)
|
||||||
Schema = new OpenApiSchema
|
|
||||||
{
|
{
|
||||||
Type = JsonSchemaType.Integer,
|
continue;
|
||||||
Format = "int32",
|
}
|
||||||
},
|
|
||||||
};
|
concreteResponse.Headers ??=
|
||||||
|
new Dictionary<string, IOpenApiHeader>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
concreteResponse.Headers["X-Rendezvous-Correlation-ID"] = new OpenApiHeader
|
||||||
|
{
|
||||||
|
Description = "Safe request correlation identifier generated by the service.",
|
||||||
|
Schema = new OpenApiSchema
|
||||||
|
{
|
||||||
|
Type = JsonSchemaType.String,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (string.Equals(
|
||||||
|
status,
|
||||||
|
StatusCodes.Status429TooManyRequests.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
concreteResponse.Headers["Retry-After"] = new OpenApiHeader
|
||||||
|
{
|
||||||
|
Description = "Whole seconds before the caller should retry (1-60).",
|
||||||
|
Schema = new OpenApiSchema
|
||||||
|
{
|
||||||
|
Type = JsonSchemaType.Integer,
|
||||||
|
Format = "int32",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,22 +218,58 @@ builder.Services
|
|||||||
&& addresses.All(
|
&& addresses.All(
|
||||||
static value => IPAddress.TryParse(value, out _)),
|
static value => IPAddress.TryParse(value, out _)),
|
||||||
"Trusted proxy addresses must contain at most 32 literal IP addresses.")
|
"Trusted proxy addresses must contain at most 32 literal IP addresses.")
|
||||||
|
.Validate(
|
||||||
|
options => options.OperatorAllowedAddresses is { Length: <= 32 } addresses
|
||||||
|
&& addresses.All(
|
||||||
|
static value => IPAddress.TryParse(value, out _)),
|
||||||
|
"Operator allowed addresses must contain at most 32 literal IP addresses.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
builder.Services.AddSingleton<AbuseProtectionService>();
|
builder.Services.AddSingleton<AbuseProtectionService>();
|
||||||
|
builder.Services
|
||||||
|
.AddOptions<AuditOptions>()
|
||||||
|
.BindConfiguration(AuditOptions.SectionName)
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.ValidateOnStart();
|
||||||
AbuseProtectionOptions configuredAbuseProtection = builder.Configuration
|
AbuseProtectionOptions configuredAbuseProtection = builder.Configuration
|
||||||
.GetSection(AbuseProtectionOptions.SectionName)
|
.GetSection(AbuseProtectionOptions.SectionName)
|
||||||
.Get<AbuseProtectionOptions>() ?? new AbuseProtectionOptions();
|
.Get<AbuseProtectionOptions>() ?? new AbuseProtectionOptions();
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
TrustedProxyForwarding.Configure(options, configuredAbuseProtection));
|
TrustedProxyForwarding.Configure(options, configuredAbuseProtection));
|
||||||
|
|
||||||
|
DeploymentOptions deploymentOptions = builder.Configuration
|
||||||
|
.GetSection(DeploymentOptions.SectionName)
|
||||||
|
.Get<DeploymentOptions>() ?? new DeploymentOptions();
|
||||||
|
if (!builder.Environment.IsDevelopment() && !isOpenApiGeneration)
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> deploymentErrors = deploymentOptions.ValidateProduction(
|
||||||
|
configuredAbuseProtection,
|
||||||
|
builder.Configuration["AllowedHosts"]);
|
||||||
|
if (deploymentErrors.Count > 0)
|
||||||
|
{
|
||||||
|
throw new DeploymentConfigurationException(deploymentErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(deploymentOptions));
|
||||||
|
builder.Services.Configure<HostOptions>(options =>
|
||||||
|
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
|
||||||
|
|
||||||
SystemRendezvousClock rendezvousClock = new();
|
SystemRendezvousClock rendezvousClock = new();
|
||||||
EphemeralStoreOptions stateOptions = new();
|
EphemeralStoreOptions stateOptions = new()
|
||||||
|
{
|
||||||
|
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
|
||||||
|
};
|
||||||
InMemoryEphemeralRendezvousStore stateStore = new(
|
InMemoryEphemeralRendezvousStore stateStore = new(
|
||||||
stateOptions,
|
stateOptions,
|
||||||
rendezvousClock,
|
rendezvousClock,
|
||||||
rendezvousClock);
|
rendezvousClock);
|
||||||
|
builder.Services.AddSingleton(stateStore);
|
||||||
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<RendezvousTelemetry>();
|
||||||
|
builder.Services.AddSingleton<AuditTrail>();
|
||||||
|
builder.Services.AddSingleton<RendezvousReadiness>();
|
||||||
|
|
||||||
if (isOpenApiGeneration)
|
if (isOpenApiGeneration)
|
||||||
{
|
{
|
||||||
@@ -214,6 +302,7 @@ else
|
|||||||
builder.Services.AddSingleton<JoinAttemptService>();
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
||||||
builder.Services.AddSingleton<ConnectionOutcomeService>();
|
builder.Services.AddSingleton<ConnectionOutcomeService>();
|
||||||
|
builder.Services.AddSingleton<OperatorService>();
|
||||||
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,43 +326,24 @@ if (!isOpenApiGeneration)
|
|||||||
builder.Services.AddSingleton<NatMediationProcessor>();
|
builder.Services.AddSingleton<NatMediationProcessor>();
|
||||||
builder.Services.AddHostedService(static services =>
|
builder.Services.AddHostedService(static services =>
|
||||||
services.GetRequiredService<UdpMediatorService>());
|
services.GetRequiredService<UdpMediatorService>());
|
||||||
|
// Hosted services stop in reverse registration order. Drain must complete while
|
||||||
|
// Kestrel and the UDP mediator are still able to finish bounded in-flight work.
|
||||||
|
builder.Services.AddHostedService<GracefulDrainService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
|
||||||
|
|
||||||
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
|
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
|
||||||
{
|
{
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
}
|
}
|
||||||
|
app.UseMiddleware<TelemetryMiddleware>();
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
app.MapRendezvousContractEndpoints();
|
app.MapRendezvousContractEndpoints();
|
||||||
app.MapGet(
|
app.MapOperatorEndpoints();
|
||||||
"/health/live",
|
app.MapRendezvousHealthEndpoints();
|
||||||
static () => Results.Ok(new HealthResponse { Status = "live" }))
|
|
||||||
.Produces<HealthResponse>()
|
|
||||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
||||||
.WithName("GetLiveness")
|
|
||||||
.WithTags("Health");
|
|
||||||
app.MapGet(
|
|
||||||
"/health/ready",
|
|
||||||
static (
|
|
||||||
UdpMediatorService mediator,
|
|
||||||
ProvisioningReadiness provisioning,
|
|
||||||
IEphemeralRendezvousStore state) =>
|
|
||||||
mediator.LocalEndpoint is null
|
|
||||||
|| !provisioning.IsReady
|
|
||||||
|| !state.IsAvailable
|
|
||||||
|| state.IsDraining
|
|
||||||
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
|
|
||||||
: Results.Ok(new HealthResponse { Status = "ready" }))
|
|
||||||
.Produces<HealthResponse>()
|
|
||||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
||||||
.Produces(StatusCodes.Status503ServiceUnavailable)
|
|
||||||
.WithName("GetReadiness")
|
|
||||||
.WithTags("Health");
|
|
||||||
|
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
|
||||||
|
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Capacity")]
|
||||||
|
|||||||
@@ -40,18 +40,32 @@ internal sealed class SecretMaterial : IDisposable
|
|||||||
|
|
||||||
internal sealed class EnvironmentSecretProvider : ISecretProvider
|
internal sealed class EnvironmentSecretProvider : ISecretProvider
|
||||||
{
|
{
|
||||||
private const string Prefix = "env:";
|
private const string EnvironmentPrefix = "env:";
|
||||||
|
private const string FilePrefix = "file:";
|
||||||
|
private const int MaximumSecretBytes = 4096;
|
||||||
|
|
||||||
public bool TryGetSecret(string reference, out SecretMaterial? secret)
|
public bool TryGetSecret(string reference, out SecretMaterial? secret)
|
||||||
{
|
{
|
||||||
secret = null;
|
secret = null;
|
||||||
if (!reference.StartsWith(Prefix, StringComparison.Ordinal)
|
if (reference.StartsWith(EnvironmentPrefix, StringComparison.Ordinal)
|
||||||
|| reference.Length == Prefix.Length)
|
&& reference.Length > EnvironmentPrefix.Length)
|
||||||
{
|
{
|
||||||
return false;
|
return TryGetEnvironmentSecret(reference[EnvironmentPrefix.Length..], out secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
string? encoded = Environment.GetEnvironmentVariable(reference[Prefix.Length..]);
|
if (reference.StartsWith(FilePrefix, StringComparison.Ordinal)
|
||||||
|
&& reference.Length > FilePrefix.Length)
|
||||||
|
{
|
||||||
|
return TryGetFileSecret(reference[FilePrefix.Length..], out secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetEnvironmentSecret(string variableName, out SecretMaterial? secret)
|
||||||
|
{
|
||||||
|
secret = null;
|
||||||
|
string? encoded = Environment.GetEnvironmentVariable(variableName);
|
||||||
if (string.IsNullOrEmpty(encoded))
|
if (string.IsNullOrEmpty(encoded))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -60,6 +74,12 @@ internal sealed class EnvironmentSecretProvider : ISecretProvider
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
byte[] bytes = Convert.FromBase64String(encoded);
|
byte[] bytes = Convert.FromBase64String(encoded);
|
||||||
|
if (bytes.Length is 0 or > MaximumSecretBytes)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
secret = new SecretMaterial(bytes);
|
secret = new SecretMaterial(bytes);
|
||||||
CryptographicOperations.ZeroMemory(bytes);
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
return true;
|
return true;
|
||||||
@@ -69,6 +89,47 @@ internal sealed class EnvironmentSecretProvider : ISecretProvider
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryGetFileSecret(string path, out SecretMaterial? secret)
|
||||||
|
{
|
||||||
|
secret = null;
|
||||||
|
byte[]? bytes = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FileInfo file = new(path);
|
||||||
|
if (!file.Exists
|
||||||
|
|| !Path.IsPathFullyQualified(path)
|
||||||
|
|| file.LinkTarget is not null
|
||||||
|
|| file.Length is <= 0 or > MaximumSecretBytes)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes = File.ReadAllBytes(path);
|
||||||
|
if (bytes.Length is 0 or > MaximumSecretBytes)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
secret = new SecretMaterial(bytes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or ArgumentException
|
||||||
|
or NotSupportedException
|
||||||
|
or System.Security.SecurityException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (bytes is not null)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class EphemeralDevelopmentSecretProvider : ISecretProvider, IDisposable
|
internal sealed class EphemeralDevelopmentSecretProvider : ISecretProvider, IDisposable
|
||||||
|
|||||||
@@ -142,8 +142,36 @@ internal sealed class SigningKeyRing : IDisposable
|
|||||||
return VerificationKeyLookup.Available;
|
return VerificationKeyLookup.Available;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Revoke(string keyId) =>
|
public bool Revoke(string keyId)
|
||||||
_keys.ContainsKey(keyId) && _runtimeRevocations.TryAdd(keyId, 0);
|
{
|
||||||
|
if (!_keys.ContainsKey(keyId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_runtimeRevocations.TryAdd(keyId, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SigningKeyStatus> GetStatuses(DateTimeOffset now) => _keys.Values
|
||||||
|
.OrderBy(static key => key.KeyId, StringComparer.Ordinal)
|
||||||
|
.Select(key => new SigningKeyStatus(
|
||||||
|
key.KeyId,
|
||||||
|
IsRevoked(key)
|
||||||
|
? "revoked"
|
||||||
|
: now < key.NotBefore
|
||||||
|
? "not-yet-valid"
|
||||||
|
: now < key.SignUntil
|
||||||
|
? "signing"
|
||||||
|
: now < key.VerifyUntil
|
||||||
|
? "verify-only"
|
||||||
|
: "retired",
|
||||||
|
key.SignUntil,
|
||||||
|
key.VerifyUntil,
|
||||||
|
key.GameId,
|
||||||
|
key.EnvironmentId,
|
||||||
|
key.CredentialKinds.Select(static kind => kind.ToString()).Order().ToArray()))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
@@ -210,6 +238,15 @@ internal sealed class SigningKeyRing : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record SigningKeyStatus(
|
||||||
|
string KeyId,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset SignUntil,
|
||||||
|
DateTimeOffset VerifyUntil,
|
||||||
|
string? GameId,
|
||||||
|
string? EnvironmentId,
|
||||||
|
IReadOnlyList<string> CredentialKinds);
|
||||||
|
|
||||||
internal sealed class SigningKey : IDisposable
|
internal sealed class SigningKey : IDisposable
|
||||||
{
|
{
|
||||||
private byte[]? _material;
|
private byte[]? _material;
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ internal sealed record StoredJoinAttempt
|
|||||||
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
||||||
public NetworkEndpoint? DedicatedFallback { get; init; }
|
public NetworkEndpoint? DedicatedFallback { get; init; }
|
||||||
public required DateTimeOffset ExpiresAt { get; init; }
|
public required DateTimeOffset ExpiresAt { get; init; }
|
||||||
|
public required TimeSpan CreatedAtMonotonic { get; init; }
|
||||||
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
||||||
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
||||||
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||||
|
|||||||
@@ -11,19 +11,36 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
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 = [];
|
||||||
|
private readonly Dictionary<string, int> _listingCountsByOwner = new(StringComparer.Ordinal);
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<SessionListingId>, long> _listingExpiries = new();
|
||||||
|
private readonly HashSet<SessionListingId> _scheduledListingExpiries = [];
|
||||||
private readonly Dictionary<LeaseId, SessionListingId> _leases = [];
|
private readonly Dictionary<LeaseId, SessionListingId> _leases = [];
|
||||||
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
|
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
|
||||||
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
|
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<MediationHandle>, long> _presenceExpiries = new();
|
||||||
|
private readonly HashSet<MediationHandle> _scheduledPresenceExpiries = [];
|
||||||
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
|
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
|
||||||
|
private readonly Dictionary<TenantScope, int> _attemptCountsByScope = [];
|
||||||
|
private readonly Dictionary<SessionListingId, HashSet<JoinAttemptId>> _attemptsByListing = [];
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<JoinAttemptId>, long> _attemptExpiries = new();
|
||||||
private readonly Dictionary<JoinAttemptId, OutcomeReportEntry> _outcomeReports = [];
|
private readonly Dictionary<JoinAttemptId, OutcomeReportEntry> _outcomeReports = [];
|
||||||
|
private readonly Dictionary<SessionListingId, HashSet<JoinAttemptId>> _outcomesByListing = [];
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<JoinAttemptId>, long> _outcomeExpiries = new();
|
||||||
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
|
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
|
||||||
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<string>, long> _idempotencyExpiries = new();
|
||||||
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<string>, long> _replayExpiries = new();
|
||||||
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
|
||||||
|
private readonly PriorityQueue<DeadlineEntry<string>, long> _revocationExpiries = new();
|
||||||
|
private readonly HashSet<string> _scheduledRevocationExpiries = new(StringComparer.Ordinal);
|
||||||
private TimeSpan? _drainDeadline;
|
private TimeSpan? _drainDeadline;
|
||||||
private TimeSpan _nextUdpMaintenance;
|
private TimeSpan _nextUdpMaintenance;
|
||||||
private long _maintenanceSweepCount;
|
private long _maintenanceSweepCount;
|
||||||
|
private long _expiryChurn;
|
||||||
private bool _available = true;
|
private bool _available = true;
|
||||||
|
private EphemeralStoreSnapshot? _metricsSnapshot;
|
||||||
|
private TimeSpan _metricsSnapshotAt = TimeSpan.MinValue;
|
||||||
|
|
||||||
public InMemoryEphemeralRendezvousStore(
|
public InMemoryEphemeralRendezvousStore(
|
||||||
EphemeralStoreOptions options,
|
EphemeralStoreOptions options,
|
||||||
@@ -43,6 +60,22 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
public Guid InstanceId { get; }
|
public Guid InstanceId { get; }
|
||||||
internal long MaintenanceSweepCount => Interlocked.Read(ref _maintenanceSweepCount);
|
internal long MaintenanceSweepCount => Interlocked.Read(ref _maintenanceSweepCount);
|
||||||
|
internal int ScheduledExpiryEntryCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _listingExpiries.Count
|
||||||
|
+ _presenceExpiries.Count
|
||||||
|
+ _attemptExpiries.Count
|
||||||
|
+ _outcomeExpiries.Count
|
||||||
|
+ _idempotencyExpiries.Count
|
||||||
|
+ _replayExpiries.Count
|
||||||
|
+ _revocationExpiries.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsAvailable
|
public bool IsAvailable
|
||||||
{
|
{
|
||||||
@@ -66,6 +99,59 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal EphemeralStoreSnapshot GetSnapshot()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
TimeSpan now = _monotonicClock.Elapsed;
|
||||||
|
Cleanup(now);
|
||||||
|
EphemeralStoreSnapshot snapshot = CreateSnapshot();
|
||||||
|
_metricsSnapshot = snapshot;
|
||||||
|
_metricsSnapshotAt = now;
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal int GetActiveJoinAttemptCountForDrain()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_expiryChurn += RemoveExpiredAttempts(_monotonicClock.Elapsed);
|
||||||
|
return _attempts.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal EphemeralStoreSnapshot GetMetricsSnapshot()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
TimeSpan now = _monotonicClock.Elapsed;
|
||||||
|
if (_metricsSnapshot is null
|
||||||
|
|| now < _metricsSnapshotAt
|
||||||
|
|| now - _metricsSnapshotAt >= TimeSpan.FromMilliseconds(100))
|
||||||
|
{
|
||||||
|
Cleanup(now);
|
||||||
|
_metricsSnapshot = CreateSnapshot();
|
||||||
|
_metricsSnapshotAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _metricsSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private EphemeralStoreSnapshot CreateSnapshot() => new(
|
||||||
|
_listings.Count,
|
||||||
|
_presence.Count,
|
||||||
|
_attempts.Count,
|
||||||
|
_outcomeReports.Count,
|
||||||
|
_replay.Count,
|
||||||
|
_revocations.Count,
|
||||||
|
_idempotency.Count,
|
||||||
|
_maintenanceSweepCount,
|
||||||
|
_expiryChurn,
|
||||||
|
_available,
|
||||||
|
_drainDeadline.HasValue);
|
||||||
|
|
||||||
public StoreResult<StoredListing> CreateListing(
|
public StoreResult<StoredListing> CreateListing(
|
||||||
CreateListingCommand command,
|
CreateListingCommand command,
|
||||||
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
|
CancellationToken cancellationToken = default) => Atomic<StoredListing>(now =>
|
||||||
@@ -104,10 +190,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
if (_listings.Count >= _options.MaxListings
|
if (_listings.Count >= _options.MaxListings
|
||||||
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
||||||
|| _listings.Values.Count(entry => string.Equals(
|
|| _listingCountsByOwner.GetValueOrDefault(command.Listing.OwnerSubject)
|
||||||
entry.Definition.OwnerSubject,
|
>= command.OwnerListingLimit)
|
||||||
command.Listing.OwnerSubject,
|
|
||||||
StringComparison.Ordinal)) >= command.OwnerListingLimit)
|
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.CapacityExceeded);
|
return new(StoreResultCode.CapacityExceeded);
|
||||||
}
|
}
|
||||||
@@ -126,12 +210,21 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
WallDeadline(now, _options.LeaseLifetime),
|
WallDeadline(now, _options.LeaseLifetime),
|
||||||
version: 1);
|
version: 1);
|
||||||
_listings.Add(frozen.ListingId, entry);
|
_listings.Add(frozen.ListingId, entry);
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_listingExpiries,
|
||||||
|
_scheduledListingExpiries,
|
||||||
|
frozen.ListingId,
|
||||||
|
entry.LeaseDeadline);
|
||||||
|
_listingCountsByOwner[frozen.OwnerSubject] =
|
||||||
|
_listingCountsByOwner.GetValueOrDefault(frozen.OwnerSubject) + 1;
|
||||||
_leases.Add(frozen.LeaseId, frozen.ListingId);
|
_leases.Add(frozen.LeaseId, frozen.ListingId);
|
||||||
_presenceHandles.Add(frozen.HostPresenceHandle, frozen.ListingId);
|
_presenceHandles.Add(frozen.HostPresenceHandle, frozen.ListingId);
|
||||||
_idempotency.Add(idempotencyKey, new(
|
IdempotencyEntry idempotency = new(
|
||||||
command.RequestFingerprint,
|
command.RequestFingerprint,
|
||||||
frozen.ListingId,
|
frozen.ListingId,
|
||||||
now + _options.IdempotencyLifetime));
|
now + _options.IdempotencyLifetime);
|
||||||
|
_idempotency.Add(idempotencyKey, idempotency);
|
||||||
|
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
|
||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
return new(StoreResultCode.Success, Snapshot(entry));
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
@@ -289,10 +382,20 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.CapacityExceeded);
|
return new(StoreResultCode.CapacityExceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
_presence[command.Handle] = new(
|
bool isNewPresence = !_presence.ContainsKey(command.Handle);
|
||||||
|
PresenceEntry presence = new(
|
||||||
command.PublicEndpoint,
|
command.PublicEndpoint,
|
||||||
command.LocalEndpoint,
|
command.LocalEndpoint,
|
||||||
now + _options.PresenceLifetime);
|
now + _options.PresenceLifetime);
|
||||||
|
_presence[command.Handle] = presence;
|
||||||
|
if (isNewPresence)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_presenceExpiries,
|
||||||
|
_scheduledPresenceExpiries,
|
||||||
|
command.Handle,
|
||||||
|
presence.Deadline);
|
||||||
|
}
|
||||||
return new(StoreResultCode.Success, Snapshot(entry));
|
return new(StoreResultCode.Success, Snapshot(entry));
|
||||||
}, cancellationToken, eagerCleanup: false);
|
}, cancellationToken, eagerCleanup: false);
|
||||||
|
|
||||||
@@ -387,8 +490,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
if (_attempts.Count >= _options.MaxJoinAttempts
|
if (_attempts.Count >= _options.MaxJoinAttempts
|
||||||
|| _outcomeReports.Count >= _options.MaxOutcomeReports
|
|| _outcomeReports.Count >= _options.MaxOutcomeReports
|
||||||
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|
||||||
|| _attempts.Values.Count(entry => entry.Command.Scope == command.Scope)
|
|| _attemptCountsByScope.GetValueOrDefault(command.Scope) >= command.ScopeAttemptLimit)
|
||||||
>= command.ScopeAttemptLimit)
|
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.CapacityExceeded);
|
return new(StoreResultCode.CapacityExceeded);
|
||||||
}
|
}
|
||||||
@@ -400,19 +502,29 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
AttemptEntry attempt = new(
|
AttemptEntry attempt = new(
|
||||||
command,
|
command,
|
||||||
|
now,
|
||||||
now + _options.JoinAttemptLifetime,
|
now + _options.JoinAttemptLifetime,
|
||||||
WallDeadline(now, _options.JoinAttemptLifetime));
|
WallDeadline(now, _options.JoinAttemptLifetime));
|
||||||
_attempts.Add(command.AttemptId, attempt);
|
_attempts.Add(command.AttemptId, attempt);
|
||||||
_outcomeReports.Add(command.AttemptId, new(
|
AddToIndex(_attemptsByListing, command.ListingId, command.AttemptId);
|
||||||
|
_attemptCountsByScope[command.Scope] =
|
||||||
|
_attemptCountsByScope.GetValueOrDefault(command.Scope) + 1;
|
||||||
|
EnqueueDeadline(_attemptExpiries, command.AttemptId, attempt.Deadline);
|
||||||
|
OutcomeReportEntry outcome = new(
|
||||||
command.ListingId,
|
command.ListingId,
|
||||||
command.ClientSubject,
|
command.ClientSubject,
|
||||||
command.ClientCapabilityFingerprint,
|
command.ClientCapabilityFingerprint,
|
||||||
now + _options.JoinAttemptLifetime + _options.IdempotencyLifetime));
|
now + _options.JoinAttemptLifetime + _options.IdempotencyLifetime);
|
||||||
|
_outcomeReports.Add(command.AttemptId, outcome);
|
||||||
|
AddToIndex(_outcomesByListing, command.ListingId, command.AttemptId);
|
||||||
|
EnqueueDeadline(_outcomeExpiries, command.AttemptId, outcome.Deadline);
|
||||||
_attemptHandles.Add(command.MediationHandle, command.AttemptId);
|
_attemptHandles.Add(command.MediationHandle, command.AttemptId);
|
||||||
_idempotency.Add(idempotencyKey, new(
|
IdempotencyEntry idempotency = new(
|
||||||
command.RequestFingerprint,
|
command.RequestFingerprint,
|
||||||
command.AttemptId,
|
command.AttemptId,
|
||||||
now + _options.IdempotencyLifetime));
|
now + _options.IdempotencyLifetime);
|
||||||
|
_idempotency.Add(idempotencyKey, idempotency);
|
||||||
|
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
|
||||||
return new(StoreResultCode.Success, Snapshot(attempt));
|
return new(StoreResultCode.Success, Snapshot(attempt));
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
@@ -696,7 +808,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
throw new ArgumentOutOfRangeException(nameof(consumption), "Replay lifetime exceeds the configured ceiling.");
|
throw new ArgumentOutOfRangeException(nameof(consumption), "Replay lifetime exceeds the configured ceiling.");
|
||||||
}
|
}
|
||||||
|
|
||||||
_replay.Add(key, now + lifetime);
|
TimeSpan deadline = now + lifetime;
|
||||||
|
_replay.Add(key, deadline);
|
||||||
|
EnqueueDeadline(_replayExpiries, key, deadline);
|
||||||
return new(StoreResultCode.Success, true);
|
return new(StoreResultCode.Success, true);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
@@ -729,7 +843,28 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.CapacityExceeded);
|
return new(StoreResultCode.CapacityExceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
_revocations[subject] = now + lifetime;
|
int activeResourcesBefore = _listings.Count
|
||||||
|
+ _presence.Count
|
||||||
|
+ _attempts.Count
|
||||||
|
+ _outcomeReports.Count;
|
||||||
|
TimeSpan deadline = now + lifetime;
|
||||||
|
bool isNewRevocation = !_revocations.TryGetValue(subject, out TimeSpan existingDeadline);
|
||||||
|
if (!isNewRevocation && existingDeadline > deadline)
|
||||||
|
{
|
||||||
|
// A repeated operator action may extend protection but cannot silently
|
||||||
|
// shorten an already-authoritative security revocation.
|
||||||
|
deadline = existingDeadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
_revocations[subject] = deadline;
|
||||||
|
if (isNewRevocation)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_revocationExpiries,
|
||||||
|
_scheduledRevocationExpiries,
|
||||||
|
subject,
|
||||||
|
deadline);
|
||||||
|
}
|
||||||
SessionListingId[] listings = _listings
|
SessionListingId[] listings = _listings
|
||||||
.Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal))
|
.Where(item => string.Equals(item.Value.Definition.OwnerSubject, subject, StringComparison.Ordinal))
|
||||||
.Select(static item => item.Key)
|
.Select(static item => item.Key)
|
||||||
@@ -753,10 +888,14 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
foreach (JoinAttemptId attemptId in outcomeReports)
|
foreach (JoinAttemptId attemptId in outcomeReports)
|
||||||
{
|
{
|
||||||
_outcomeReports.Remove(attemptId);
|
RemoveOutcome(attemptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new(StoreResultCode.Success, listings.Length + attempts.Length);
|
int activeResourcesAfter = _listings.Count
|
||||||
|
+ _presence.Count
|
||||||
|
+ _attempts.Count
|
||||||
|
+ _outcomeReports.Count;
|
||||||
|
return new(StoreResultCode.Success, activeResourcesBefore - activeResourcesAfter);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
public void BeginDrain(CancellationToken cancellationToken = default)
|
public void BeginDrain(CancellationToken cancellationToken = default)
|
||||||
@@ -768,6 +907,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
if (!_drainDeadline.HasValue)
|
if (!_drainDeadline.HasValue)
|
||||||
{
|
{
|
||||||
_drainDeadline = _monotonicClock.Elapsed + _options.GracefulDrainLifetime;
|
_drainDeadline = _monotonicClock.Elapsed + _options.GracefulDrainLifetime;
|
||||||
|
_metricsSnapshot = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,6 +918,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
{
|
{
|
||||||
_available = false;
|
_available = false;
|
||||||
ClearActiveState();
|
ClearActiveState();
|
||||||
|
_metricsSnapshot = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,7 +942,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
_nextUdpMaintenance = now + UdpMaintenanceInterval;
|
_nextUdpMaintenance = now + UdpMaintenanceInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
return operation(now);
|
StoreResult<T> result = operation(now);
|
||||||
|
_metricsSnapshot = null;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,60 +981,42 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
ClearActiveState();
|
ClearActiveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
RemoveExpired(_revocations, now);
|
_expiryChurn += RemoveExpiredMutableDeadlines(
|
||||||
RemoveExpired(_replay, now);
|
_revocations,
|
||||||
foreach (string key in _idempotency
|
_revocationExpiries,
|
||||||
.Where(item => item.Value.Deadline <= now)
|
_scheduledRevocationExpiries,
|
||||||
.Select(static item => item.Key)
|
now);
|
||||||
.ToArray())
|
_expiryChurn += RemoveExpiredDeadlines(_replay, _replayExpiries, now);
|
||||||
{
|
_expiryChurn += RemoveExpiredIdempotency(now);
|
||||||
_idempotency.Remove(key);
|
_expiryChurn += RemoveExpiredPresence(now);
|
||||||
}
|
_expiryChurn += RemoveExpiredAttempts(now);
|
||||||
|
_expiryChurn += RemoveExpiredOutcomes(now);
|
||||||
foreach (MediationHandle handle in _presence
|
_expiryChurn += RemoveExpiredListings(now);
|
||||||
.Where(item => item.Value.Deadline <= now)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray())
|
|
||||||
{
|
|
||||||
_presence.Remove(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (JoinAttemptId attemptId in _attempts
|
|
||||||
.Where(item => item.Value.Deadline <= now)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray())
|
|
||||||
{
|
|
||||||
RemoveAttempt(attemptId);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (JoinAttemptId attemptId in _outcomeReports
|
|
||||||
.Where(item => item.Value.Deadline <= now)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray())
|
|
||||||
{
|
|
||||||
_outcomeReports.Remove(attemptId);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (SessionListingId listingId in _listings
|
|
||||||
.Where(item => item.Value.LeaseDeadline <= now)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray())
|
|
||||||
{
|
|
||||||
RemoveListing(listingId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ClearActiveState()
|
private void ClearActiveState()
|
||||||
{
|
{
|
||||||
_listings.Clear();
|
_listings.Clear();
|
||||||
|
_listingCountsByOwner.Clear();
|
||||||
|
_listingExpiries.Clear();
|
||||||
|
_scheduledListingExpiries.Clear();
|
||||||
_leases.Clear();
|
_leases.Clear();
|
||||||
_presenceHandles.Clear();
|
_presenceHandles.Clear();
|
||||||
_presence.Clear();
|
_presence.Clear();
|
||||||
|
_presenceExpiries.Clear();
|
||||||
|
_scheduledPresenceExpiries.Clear();
|
||||||
_attempts.Clear();
|
_attempts.Clear();
|
||||||
|
_attemptCountsByScope.Clear();
|
||||||
|
_attemptsByListing.Clear();
|
||||||
|
_attemptExpiries.Clear();
|
||||||
_outcomeReports.Clear();
|
_outcomeReports.Clear();
|
||||||
|
_outcomesByListing.Clear();
|
||||||
|
_outcomeExpiries.Clear();
|
||||||
_attemptHandles.Clear();
|
_attemptHandles.Clear();
|
||||||
_idempotency.Clear();
|
_idempotency.Clear();
|
||||||
|
_idempotencyExpiries.Clear();
|
||||||
_replay.Clear();
|
_replay.Clear();
|
||||||
|
_replayExpiries.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveListing(SessionListingId listingId)
|
private void RemoveListing(SessionListingId listingId)
|
||||||
@@ -902,23 +1027,23 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
_leases.Remove(listing.Definition.LeaseId);
|
_leases.Remove(listing.Definition.LeaseId);
|
||||||
|
DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject);
|
||||||
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
|
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
|
||||||
_presence.Remove(listing.Definition.HostPresenceHandle);
|
_presence.Remove(listing.Definition.HostPresenceHandle);
|
||||||
foreach (JoinAttemptId attemptId in _attempts
|
if (_attemptsByListing.TryGetValue(listingId, out HashSet<JoinAttemptId>? attempts))
|
||||||
.Where(item => item.Value.Command.ListingId == listingId)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray())
|
|
||||||
{
|
{
|
||||||
RemoveAttempt(attemptId);
|
foreach (JoinAttemptId attemptId in attempts.ToArray())
|
||||||
|
{
|
||||||
|
RemoveAttempt(attemptId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_outcomesByListing.TryGetValue(listingId, out HashSet<JoinAttemptId>? outcomes))
|
||||||
foreach (JoinAttemptId attemptId in _outcomeReports
|
|
||||||
.Where(item => item.Value.ListingId == listingId)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray())
|
|
||||||
{
|
{
|
||||||
_outcomeReports.Remove(attemptId);
|
foreach (JoinAttemptId attemptId in outcomes.ToArray())
|
||||||
|
{
|
||||||
|
RemoveOutcome(attemptId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,9 +1052,260 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
if (_attempts.Remove(attemptId, out AttemptEntry? attempt))
|
if (_attempts.Remove(attemptId, out AttemptEntry? attempt))
|
||||||
{
|
{
|
||||||
_attemptHandles.Remove(attempt.Command.MediationHandle);
|
_attemptHandles.Remove(attempt.Command.MediationHandle);
|
||||||
|
DecrementCount(_attemptCountsByScope, attempt.Command.Scope);
|
||||||
|
RemoveFromIndex(_attemptsByListing, attempt.Command.ListingId, attemptId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RemoveOutcome(JoinAttemptId attemptId)
|
||||||
|
{
|
||||||
|
if (_outcomeReports.Remove(attemptId, out OutcomeReportEntry? outcome))
|
||||||
|
{
|
||||||
|
RemoveFromIndex(_outcomesByListing, outcome.ListingId, attemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddToIndex<TKey>(
|
||||||
|
Dictionary<TKey, HashSet<JoinAttemptId>> index,
|
||||||
|
TKey key,
|
||||||
|
JoinAttemptId attemptId)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
if (!index.TryGetValue(key, out HashSet<JoinAttemptId>? values))
|
||||||
|
{
|
||||||
|
values = [];
|
||||||
|
index.Add(key, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.Add(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveFromIndex<TKey>(
|
||||||
|
Dictionary<TKey, HashSet<JoinAttemptId>> index,
|
||||||
|
TKey key,
|
||||||
|
JoinAttemptId attemptId)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
if (index.TryGetValue(key, out HashSet<JoinAttemptId>? values)
|
||||||
|
&& values.Remove(attemptId)
|
||||||
|
&& values.Count == 0)
|
||||||
|
{
|
||||||
|
index.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DecrementCount<TKey>(Dictionary<TKey, int> counts, TKey key)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
int remaining = counts[key] - 1;
|
||||||
|
if (remaining == 0)
|
||||||
|
{
|
||||||
|
counts.Remove(key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
counts[key] = remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int RemoveExpiredAttempts(TimeSpan now)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (_attemptExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<JoinAttemptId> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
_attemptExpiries.Dequeue();
|
||||||
|
if (_attempts.TryGetValue(candidate.Key, out AttemptEntry? current)
|
||||||
|
&& current.Deadline == candidate.Deadline)
|
||||||
|
{
|
||||||
|
RemoveAttempt(candidate.Key);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int RemoveExpiredListings(TimeSpan now)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (_listingExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<SessionListingId> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
_listingExpiries.Dequeue();
|
||||||
|
_scheduledListingExpiries.Remove(candidate.Key);
|
||||||
|
if (!_listings.TryGetValue(candidate.Key, out ListingEntry? current))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.LeaseDeadline > now)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_listingExpiries,
|
||||||
|
_scheduledListingExpiries,
|
||||||
|
candidate.Key,
|
||||||
|
current.LeaseDeadline);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RemoveListing(candidate.Key);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int RemoveExpiredPresence(TimeSpan now)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (_presenceExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<MediationHandle> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
_presenceExpiries.Dequeue();
|
||||||
|
_scheduledPresenceExpiries.Remove(candidate.Key);
|
||||||
|
if (!_presence.TryGetValue(candidate.Key, out PresenceEntry? current))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Deadline > now)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(
|
||||||
|
_presenceExpiries,
|
||||||
|
_scheduledPresenceExpiries,
|
||||||
|
candidate.Key,
|
||||||
|
current.Deadline);
|
||||||
|
}
|
||||||
|
else if (_presence.Remove(candidate.Key))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int RemoveExpiredOutcomes(TimeSpan now)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (_outcomeExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<JoinAttemptId> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
_outcomeExpiries.Dequeue();
|
||||||
|
if (_outcomeReports.TryGetValue(candidate.Key, out OutcomeReportEntry? current)
|
||||||
|
&& current.Deadline == candidate.Deadline)
|
||||||
|
{
|
||||||
|
RemoveOutcome(candidate.Key);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int RemoveExpiredIdempotency(TimeSpan now)
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (_idempotencyExpiries.TryPeek(
|
||||||
|
out DeadlineEntry<string> candidate,
|
||||||
|
out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
_idempotencyExpiries.Dequeue();
|
||||||
|
if (_idempotency.TryGetValue(candidate.Key, out IdempotencyEntry? current)
|
||||||
|
&& current.Deadline == candidate.Deadline
|
||||||
|
&& _idempotency.Remove(candidate.Key))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RemoveExpiredDeadlines<TKey>(
|
||||||
|
Dictionary<TKey, TimeSpan> entries,
|
||||||
|
PriorityQueue<DeadlineEntry<TKey>, long> expiries,
|
||||||
|
TimeSpan now)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (expiries.TryPeek(out DeadlineEntry<TKey> candidate, out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
expiries.Dequeue();
|
||||||
|
if (entries.TryGetValue(candidate.Key, out TimeSpan current)
|
||||||
|
&& current == candidate.Deadline
|
||||||
|
&& entries.Remove(candidate.Key))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RemoveExpiredMutableDeadlines<TKey>(
|
||||||
|
Dictionary<TKey, TimeSpan> entries,
|
||||||
|
PriorityQueue<DeadlineEntry<TKey>, long> expiries,
|
||||||
|
HashSet<TKey> scheduled,
|
||||||
|
TimeSpan now)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
while (expiries.TryPeek(out DeadlineEntry<TKey> candidate, out long deadlineTicks)
|
||||||
|
&& deadlineTicks <= now.Ticks)
|
||||||
|
{
|
||||||
|
expiries.Dequeue();
|
||||||
|
scheduled.Remove(candidate.Key);
|
||||||
|
if (!entries.TryGetValue(candidate.Key, out TimeSpan current))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current > now)
|
||||||
|
{
|
||||||
|
ScheduleMutableDeadline(expiries, scheduled, candidate.Key, current);
|
||||||
|
}
|
||||||
|
else if (entries.Remove(candidate.Key))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ScheduleMutableDeadline<TKey>(
|
||||||
|
PriorityQueue<DeadlineEntry<TKey>, long> expiries,
|
||||||
|
HashSet<TKey> scheduled,
|
||||||
|
TKey key,
|
||||||
|
TimeSpan deadline)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
if (scheduled.Add(key))
|
||||||
|
{
|
||||||
|
EnqueueDeadline(expiries, key, deadline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnqueueDeadline<TKey>(
|
||||||
|
PriorityQueue<DeadlineEntry<TKey>, long> expiries,
|
||||||
|
TKey key,
|
||||||
|
TimeSpan deadline)
|
||||||
|
where TKey : notnull =>
|
||||||
|
expiries.Enqueue(new(key, deadline), deadline.Ticks);
|
||||||
|
|
||||||
private bool HandleExists(MediationHandle handle) =>
|
private bool HandleExists(MediationHandle handle) =>
|
||||||
_presenceHandles.ContainsKey(handle) || _attemptHandles.ContainsKey(handle);
|
_presenceHandles.ContainsKey(handle) || _attemptHandles.ContainsKey(handle);
|
||||||
|
|
||||||
@@ -959,6 +1335,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
|
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
|
||||||
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
|
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
|
||||||
DedicatedFallback = StoredListing.CopyEndpoint(entry.Command.DedicatedFallback),
|
DedicatedFallback = StoredListing.CopyEndpoint(entry.Command.DedicatedFallback),
|
||||||
|
CreatedAtMonotonic = entry.CreatedAtMonotonic,
|
||||||
ExpiresAt = entry.WallExpiresAt,
|
ExpiresAt = entry.WallExpiresAt,
|
||||||
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
|
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
|
||||||
HostEndpoint = entry.HostEndpoint,
|
HostEndpoint = entry.HostEndpoint,
|
||||||
@@ -968,17 +1345,6 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
IsCancelled = entry.IsCancelled,
|
IsCancelled = entry.IsCancelled,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
|
||||||
{
|
|
||||||
foreach (string key in entries
|
|
||||||
.Where(item => item.Value <= now)
|
|
||||||
.Select(static item => item.Key)
|
|
||||||
.ToArray())
|
|
||||||
{
|
|
||||||
entries.Remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateListing(ListingDefinition listing)
|
private static void ValidateListing(ListingDefinition listing)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(listing);
|
ArgumentNullException.ThrowIfNull(listing);
|
||||||
@@ -1101,10 +1467,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
private sealed class AttemptEntry(
|
private sealed class AttemptEntry(
|
||||||
CreateJoinAttemptCommand command,
|
CreateJoinAttemptCommand command,
|
||||||
|
TimeSpan createdAtMonotonic,
|
||||||
TimeSpan deadline,
|
TimeSpan deadline,
|
||||||
DateTimeOffset wallExpiresAt)
|
DateTimeOffset wallExpiresAt)
|
||||||
{
|
{
|
||||||
public CreateJoinAttemptCommand Command { get; } = command;
|
public CreateJoinAttemptCommand Command { get; } = command;
|
||||||
|
public TimeSpan CreatedAtMonotonic { get; } = createdAtMonotonic;
|
||||||
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
|
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
|
||||||
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
|
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
|
||||||
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
|
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
|
||||||
@@ -1119,6 +1487,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
public bool IsCancelled { get; set; }
|
public bool IsCancelled { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct DeadlineEntry<TKey>(TKey Key, TimeSpan Deadline)
|
||||||
|
where TKey : notnull;
|
||||||
|
|
||||||
private sealed class OutcomeReportEntry(
|
private sealed class OutcomeReportEntry(
|
||||||
SessionListingId listingId,
|
SessionListingId listingId,
|
||||||
string clientSubject,
|
string clientSubject,
|
||||||
@@ -1137,3 +1508,16 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
object ResourceId,
|
object ResourceId,
|
||||||
TimeSpan Deadline);
|
TimeSpan Deadline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record EphemeralStoreSnapshot(
|
||||||
|
int ActiveListings,
|
||||||
|
int FreshPresenceBindings,
|
||||||
|
int ActiveJoinAttempts,
|
||||||
|
int RetainedOutcomeReports,
|
||||||
|
int ReplayMarkers,
|
||||||
|
int PrincipalRevocations,
|
||||||
|
int IdempotencyEntries,
|
||||||
|
long MaintenanceSweeps,
|
||||||
|
long ExpiryChurn,
|
||||||
|
bool IsAvailable,
|
||||||
|
bool IsDraining);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
using FinalFactory.Rendezvous.Server.Abuse;
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
using FinalFactory.Rendezvous.Server.Sessions;
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
using FinalFactory.Rendezvous.Server.State;
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
@@ -38,7 +40,9 @@ internal sealed class NatMediationProcessor(
|
|||||||
IEphemeralRendezvousStore store,
|
IEphemeralRendezvousStore store,
|
||||||
ISessionCapabilityService capabilities,
|
ISessionCapabilityService capabilities,
|
||||||
JoinAttemptService joinAttempts,
|
JoinAttemptService joinAttempts,
|
||||||
AbuseProtectionService? abuseProtection = null)
|
AbuseProtectionService? abuseProtection = null,
|
||||||
|
RendezvousTelemetry? telemetry = null,
|
||||||
|
IMonotonicClock? monotonicClock = null)
|
||||||
{
|
{
|
||||||
public NatMediationResult ProcessDatagram(
|
public NatMediationResult ProcessDatagram(
|
||||||
ReadOnlySpan<byte> encoded,
|
ReadOnlySpan<byte> encoded,
|
||||||
@@ -68,6 +72,26 @@ internal sealed class NatMediationProcessor(
|
|||||||
INatIntroductionSink introductionSink,
|
INatIntroductionSink introductionSink,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
using Activity? activity = telemetry?.StartActivity("UDP frozen", ActivityKind.Server);
|
||||||
|
NatMediationResult result = ProcessDatagramCore(
|
||||||
|
encoded,
|
||||||
|
observedPublicEndpoint,
|
||||||
|
introductionSink,
|
||||||
|
cancellationToken);
|
||||||
|
telemetry?.RecordUdp(
|
||||||
|
"frozen",
|
||||||
|
result.ToString(),
|
||||||
|
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private NatMediationResult ProcessDatagramCore(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
IPEndPoint observedPublicEndpoint,
|
||||||
|
INatIntroductionSink introductionSink,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
|
||||||
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||||
|| datagram is null
|
|| datagram is null
|
||||||
@@ -141,12 +165,22 @@ internal sealed class NatMediationProcessor(
|
|||||||
IPEndPoint observedPublicEndpoint,
|
IPEndPoint observedPublicEndpoint,
|
||||||
string token,
|
string token,
|
||||||
INatIntroductionSink introductionSink,
|
INatIntroductionSink introductionSink,
|
||||||
CancellationToken cancellationToken = default) => ProcessRequestCore(
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
using Activity? activity = telemetry?.StartActivity("UDP litenet", ActivityKind.Server);
|
||||||
|
NatMediationResult result = ProcessRequestCore(
|
||||||
claimedLocalEndpoint,
|
claimedLocalEndpoint,
|
||||||
observedPublicEndpoint,
|
observedPublicEndpoint,
|
||||||
token,
|
token,
|
||||||
introductionSink,
|
introductionSink,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
telemetry?.RecordUdp(
|
||||||
|
"litenet",
|
||||||
|
result.ToString(),
|
||||||
|
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private NatMediationResult ProcessRequestCore(
|
private NatMediationResult ProcessRequestCore(
|
||||||
IPEndPoint claimedLocalEndpoint,
|
IPEndPoint claimedLocalEndpoint,
|
||||||
@@ -255,6 +289,14 @@ internal sealed class NatMediationProcessor(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value));
|
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value));
|
||||||
|
if (telemetry is not null && monotonicClock is not null)
|
||||||
|
{
|
||||||
|
telemetry.RecordPairingLatency(Math.Max(
|
||||||
|
0,
|
||||||
|
(monotonicClock.Elapsed - consumed.Value.Attempt.CreatedAtMonotonic)
|
||||||
|
.TotalMilliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
return NatMediationResult.Introduced;
|
return NatMediationResult.Introduced;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is SocketException
|
catch (Exception exception) when (exception is SocketException
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"Rendezvous": {
|
"Rendezvous": {
|
||||||
|
"AbuseProtection": {
|
||||||
|
"OperatorAllowedAddresses": ["127.0.0.1", "::1"]
|
||||||
|
},
|
||||||
"Provisioning": {
|
"Provisioning": {
|
||||||
"Issuer": "final-factory-rendezvous-development",
|
"Issuer": "final-factory-rendezvous-development",
|
||||||
"Audience": "final-factory-rendezvous",
|
"Audience": "final-factory-rendezvous",
|
||||||
@@ -14,6 +17,14 @@
|
|||||||
"NotBefore": "2025-01-01T00:00:00Z",
|
"NotBefore": "2025-01-01T00:00:00Z",
|
||||||
"SignUntil": "2035-01-01T00:00:00Z",
|
"SignUntil": "2035-01-01T00:00:00Z",
|
||||||
"VerifyUntil": "2035-01-02T00:00:00Z"
|
"VerifyUntil": "2035-01-02T00:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"KeyId": "development-operator-1",
|
||||||
|
"SecretReference": "development:ephemeral/rendezvous-operator-signing",
|
||||||
|
"CredentialKinds": ["Operator"],
|
||||||
|
"NotBefore": "2025-01-01T00:00:00Z",
|
||||||
|
"SignUntil": "2035-01-01T00:00:00Z",
|
||||||
|
"VerifyUntil": "2035-01-02T00:00:00Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Games": [
|
"Games": [
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
{
|
{
|
||||||
"Rendezvous": {
|
"Rendezvous": {
|
||||||
|
"Deployment": {
|
||||||
|
"PublicHttpBaseUrl": "",
|
||||||
|
"PublicUdpHost": "",
|
||||||
|
"PublicUdpPort": 9050,
|
||||||
|
"DrainDeadlineSeconds": 30,
|
||||||
|
"MinimumDrainSeconds": 1,
|
||||||
|
"SingleActiveInstance": true,
|
||||||
|
"AllowPrivatePublicEndpoints": false
|
||||||
|
},
|
||||||
"Udp": {
|
"Udp": {
|
||||||
"ListenAddress": "0.0.0.0",
|
"ListenAddress": "0.0.0.0",
|
||||||
"Port": 9050,
|
"Port": 9050,
|
||||||
"MaxDatagramsPerPoll": 256,
|
"MaxDatagramsPerPoll": 256,
|
||||||
"PollIntervalMilliseconds": 2
|
"PollIntervalMilliseconds": 2
|
||||||
},
|
},
|
||||||
|
"Audit": {
|
||||||
|
"MaxEntries": 10000,
|
||||||
|
"RetentionDays": 30
|
||||||
|
},
|
||||||
"AbuseProtection": {
|
"AbuseProtection": {
|
||||||
"WindowSeconds": 1,
|
"WindowSeconds": 1,
|
||||||
"MaxTrackedKeys": 100000,
|
"MaxTrackedKeys": 100000,
|
||||||
"CriticalTrackedKeyReserve": 2048,
|
"CriticalTrackedKeyReserve": 2048,
|
||||||
"UdpTrackedKeyLimit": 70000,
|
"UdpTrackedKeyLimit": 70000,
|
||||||
"TrustedProxyAddresses": [],
|
"TrustedProxyAddresses": [],
|
||||||
|
"OperatorAllowedAddresses": [],
|
||||||
"HealthGlobalRequestsPerWindow": 1000,
|
"HealthGlobalRequestsPerWindow": 1000,
|
||||||
"HealthGlobalConcurrency": 32,
|
"HealthGlobalConcurrency": 32,
|
||||||
"HealthIpPrefixRequestsPerWindow": 120,
|
"HealthIpPrefixRequestsPerWindow": 120,
|
||||||
"HealthIpPrefixConcurrency": 8,
|
"HealthIpPrefixConcurrency": 8,
|
||||||
|
"OperatorGlobalRequestsPerWindow": 1000,
|
||||||
|
"OperatorGlobalConcurrency": 32,
|
||||||
|
"OperatorIpPrefixRequestsPerWindow": 120,
|
||||||
|
"OperatorIpPrefixConcurrency": 8,
|
||||||
"HttpGlobalRequestsPerWindow": 20000,
|
"HttpGlobalRequestsPerWindow": 20000,
|
||||||
"HttpOptionalRequestsPerWindow": 18000,
|
"HttpOptionalRequestsPerWindow": 18000,
|
||||||
"HttpIpPrefixRequestsPerWindow": 500,
|
"HttpIpPrefixRequestsPerWindow": 500,
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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,91 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.Capacity;
|
||||||
|
|
||||||
|
internal sealed record CapacityOptions
|
||||||
|
{
|
||||||
|
public required string Profile { get; init; }
|
||||||
|
public required int Listings { get; init; }
|
||||||
|
public required int Attempts { get; init; }
|
||||||
|
public required int Samples { get; init; }
|
||||||
|
public required int SoakCycles { get; init; }
|
||||||
|
public required int SoakSeconds { get; init; }
|
||||||
|
public string? OutputPath { get; init; }
|
||||||
|
|
||||||
|
public static CapacityOptions Parse(string[] args)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> values = ParseArguments(args);
|
||||||
|
string profile = values.GetValueOrDefault("--profile") ?? "quick";
|
||||||
|
(int listings, int attempts, int samples, int soakCycles, int soakSeconds) = profile switch
|
||||||
|
{
|
||||||
|
"quick" => (1_000, 500, 100, 20, 0),
|
||||||
|
"candidate" => (25_000, 10_000, 1_000, 1_000, 300),
|
||||||
|
_ => throw new ArgumentException("--profile must be 'quick' or 'candidate'."),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
Profile = profile,
|
||||||
|
Listings = ParsePositive(values.GetValueOrDefault("--listings"), listings, "--listings"),
|
||||||
|
Attempts = ParsePositive(values.GetValueOrDefault("--attempts"), attempts, "--attempts"),
|
||||||
|
Samples = ParsePositive(values.GetValueOrDefault("--samples"), samples, "--samples"),
|
||||||
|
SoakCycles = ParsePositive(
|
||||||
|
values.GetValueOrDefault("--soak-cycles"),
|
||||||
|
soakCycles,
|
||||||
|
"--soak-cycles"),
|
||||||
|
SoakSeconds = ParseNonNegative(
|
||||||
|
values.GetValueOrDefault("--soak-seconds"),
|
||||||
|
soakSeconds,
|
||||||
|
"--soak-seconds"),
|
||||||
|
OutputPath = values.GetValueOrDefault("--output"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseArguments(string[] args)
|
||||||
|
{
|
||||||
|
HashSet<string> allowed =
|
||||||
|
[
|
||||||
|
"--profile",
|
||||||
|
"--listings",
|
||||||
|
"--attempts",
|
||||||
|
"--samples",
|
||||||
|
"--soak-cycles",
|
||||||
|
"--soak-seconds",
|
||||||
|
"--output",
|
||||||
|
];
|
||||||
|
Dictionary<string, string> values = new(StringComparer.Ordinal);
|
||||||
|
for (int index = 0; index < args.Length; index += 2)
|
||||||
|
{
|
||||||
|
string option = args[index];
|
||||||
|
if (!allowed.Contains(option))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Unknown option: {option}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == args.Length - 1
|
||||||
|
|| args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{option} requires a value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.TryAdd(option, args[index + 1]))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{option} may be supplied only once.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParsePositive(string? value, int fallback, string option) =>
|
||||||
|
value is null
|
||||||
|
? fallback
|
||||||
|
: int.TryParse(value, out int parsed) && parsed > 0
|
||||||
|
? parsed
|
||||||
|
: throw new ArgumentException($"{option} must be a positive integer.");
|
||||||
|
|
||||||
|
private static int ParseNonNegative(string? value, int fallback, string option) =>
|
||||||
|
value is null
|
||||||
|
? fallback
|
||||||
|
: int.TryParse(value, out int parsed) && parsed >= 0
|
||||||
|
? parsed
|
||||||
|
: throw new ArgumentException($"{option} must be a non-negative integer.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
namespace FinalFactory.Rendezvous.Capacity;
|
||||||
|
|
||||||
|
internal sealed record CapacityReport
|
||||||
|
{
|
||||||
|
public required int SchemaVersion { get; init; }
|
||||||
|
public required string EvidenceVersion { get; init; }
|
||||||
|
public required DateTimeOffset GeneratedAt { get; init; }
|
||||||
|
public required string Profile { get; init; }
|
||||||
|
public required RuntimeEvidence Runtime { get; init; }
|
||||||
|
public required CapacityTargets Targets { get; init; }
|
||||||
|
public required IReadOnlyList<CapacityMeasurement> Measurements { get; init; }
|
||||||
|
public required StateEvidence State { get; init; }
|
||||||
|
public required IReadOnlyList<string> Failures { get; init; }
|
||||||
|
public required bool Passed { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RuntimeEvidence(
|
||||||
|
string Framework,
|
||||||
|
string OperatingSystem,
|
||||||
|
string Kernel,
|
||||||
|
string Architecture,
|
||||||
|
string CpuModel,
|
||||||
|
int ProcessorCount,
|
||||||
|
string CpuAffinity,
|
||||||
|
string CpuQuota,
|
||||||
|
string MemoryLimit,
|
||||||
|
string GarbageCollector,
|
||||||
|
string CommitSha,
|
||||||
|
string TreeState,
|
||||||
|
string Command,
|
||||||
|
string ImageDigest,
|
||||||
|
string WorkloadSeed,
|
||||||
|
double CapacityPhaseAverageCpuPercent,
|
||||||
|
long PeakWorkingSetBytes,
|
||||||
|
long ManagedBytesAfterCleanup);
|
||||||
|
|
||||||
|
internal sealed record CapacityTargets(
|
||||||
|
int VisibleListings,
|
||||||
|
int ActiveJoinAttempts,
|
||||||
|
int CoreControlOperationsPerSecond,
|
||||||
|
int CoreMediationOperationsPerSecond,
|
||||||
|
double CoreControlP95Milliseconds,
|
||||||
|
double CoreMediationP95Milliseconds,
|
||||||
|
double MaximumAverageCpuPercent,
|
||||||
|
long MaximumWorkingSetBytes,
|
||||||
|
int SoakCycles,
|
||||||
|
int SoakDurationSeconds);
|
||||||
|
|
||||||
|
internal sealed record CapacityMeasurement(
|
||||||
|
string Operation,
|
||||||
|
int Samples,
|
||||||
|
double P50Milliseconds,
|
||||||
|
double P95Milliseconds,
|
||||||
|
double P99Milliseconds,
|
||||||
|
double OperationsPerSecond,
|
||||||
|
double MinimumOperationsPerSecond,
|
||||||
|
double BudgetMilliseconds,
|
||||||
|
bool Passed);
|
||||||
|
|
||||||
|
internal sealed record StateEvidence(
|
||||||
|
int PeakListings,
|
||||||
|
int PeakAttempts,
|
||||||
|
int PeakReplayMarkers,
|
||||||
|
int FinalListings,
|
||||||
|
int FinalAttempts,
|
||||||
|
int FinalReplayMarkers,
|
||||||
|
long ExpiryChurn,
|
||||||
|
long MaintenanceSweeps,
|
||||||
|
int SoakCyclesCompleted,
|
||||||
|
double SoakDurationSeconds,
|
||||||
|
int SoakPeakScheduledExpiryEntries,
|
||||||
|
long SoakManagedGrowthBytes,
|
||||||
|
int SoakHandleGrowth,
|
||||||
|
bool RestartStartedEmpty,
|
||||||
|
bool OverloadWasTyped,
|
||||||
|
bool RecoverySucceeded);
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Capacity;
|
||||||
|
|
||||||
|
internal static class CapacityRunner
|
||||||
|
{
|
||||||
|
private static readonly TenantScope Scope = new(new("space-game"), new("production"));
|
||||||
|
private const uint ProtocolVersion = 1;
|
||||||
|
|
||||||
|
public static Task<CapacityReport> RunAsync(CapacityOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
Process process = Process.GetCurrentProcess();
|
||||||
|
TimeSpan cpuBefore = process.TotalProcessorTime;
|
||||||
|
Stopwatch capacityPhaseTime = Stopwatch.StartNew();
|
||||||
|
List<string> failures = [];
|
||||||
|
List<CapacityMeasurement> measurements = [];
|
||||||
|
ManualClock clock = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = CreateStore(options, clock);
|
||||||
|
List<StoredListing> listings = new(options.Listings);
|
||||||
|
List<CreateJoinAttemptCommand> attempts = new(options.Attempts);
|
||||||
|
int registrationSamples = Math.Min(options.Samples, options.Listings);
|
||||||
|
int attemptSamples = Math.Min(options.Samples, options.Attempts);
|
||||||
|
|
||||||
|
for (int index = 0; index < options.Listings - registrationSamples; index++)
|
||||||
|
{
|
||||||
|
listings.Add(CreateVisibleListing(store, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"registration-and-presence",
|
||||||
|
registrationSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 200,
|
||||||
|
index => listings.Add(CreateVisibleListing(
|
||||||
|
store,
|
||||||
|
options.Listings - registrationSamples + index))));
|
||||||
|
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"lease-renewal",
|
||||||
|
registrationSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 200,
|
||||||
|
index =>
|
||||||
|
{
|
||||||
|
StoredListing listing = listings[index];
|
||||||
|
StoreResult<StoredListing> renewed = store.RenewLease(new(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
listing.Definition.LeaseId,
|
||||||
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
|
listing.Version));
|
||||||
|
RequireSuccess(renewed, "renewal");
|
||||||
|
listings[index] = renewed.Value!;
|
||||||
|
}));
|
||||||
|
|
||||||
|
int browseSamples = Math.Min(options.Samples, 250);
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"visible-session-browse",
|
||||||
|
browseSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 200,
|
||||||
|
_ => RequireSuccess(
|
||||||
|
store.BrowseVisibleListings(new(
|
||||||
|
Scope,
|
||||||
|
ProtocolVersion,
|
||||||
|
new RegionId("eu-central"),
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
ExcludeFull: true)),
|
||||||
|
"browse")));
|
||||||
|
|
||||||
|
for (int index = 0; index < options.Attempts - attemptSamples; index++)
|
||||||
|
{
|
||||||
|
CreateJoinAttemptCommand command = CreateAttempt(index, listings[index % listings.Count]);
|
||||||
|
RequireSuccess(store.CreateJoinAttempt(command), "join issuance");
|
||||||
|
attempts.Add(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"join-attempt-issuance",
|
||||||
|
attemptSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 200,
|
||||||
|
index =>
|
||||||
|
{
|
||||||
|
int sequence = options.Attempts - attemptSamples + index;
|
||||||
|
CreateJoinAttemptCommand command = CreateAttempt(
|
||||||
|
sequence,
|
||||||
|
listings[sequence % listings.Count]);
|
||||||
|
RequireSuccess(store.CreateJoinAttempt(command), "join issuance");
|
||||||
|
attempts.Add(command);
|
||||||
|
}));
|
||||||
|
|
||||||
|
int punchSamples = Math.Min(options.Samples, attempts.Count);
|
||||||
|
measurements.Add(MeasureConcurrentPunch(store, attempts, punchSamples));
|
||||||
|
EphemeralStoreSnapshot peak = store.GetSnapshot();
|
||||||
|
|
||||||
|
StoreResult<StoredJoinAttempt> overloaded = store.CreateJoinAttempt(
|
||||||
|
CreateAttempt(options.Attempts + 1, listings[^1]));
|
||||||
|
bool overloadWasTyped = peak.ActiveJoinAttempts == options.Attempts
|
||||||
|
&& overloaded.Code == StoreResultCode.CapacityExceeded;
|
||||||
|
if (!overloadWasTyped)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Expected typed CapacityExceeded at {options.Attempts} active attempts, "
|
||||||
|
+ $"observed count={peak.ActiveJoinAttempts}, result={overloaded.Code}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int revocationSamples = Math.Min(Math.Max(1, options.Samples / 20), listings.Count / 2);
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"principal-revocation",
|
||||||
|
revocationSamples,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 50,
|
||||||
|
index => RequireSuccess(
|
||||||
|
store.RevokePrincipal(
|
||||||
|
listings[index].Definition.OwnerSubject,
|
||||||
|
TimeSpan.FromMinutes(1)),
|
||||||
|
"principal revocation")));
|
||||||
|
|
||||||
|
using (RendezvousTelemetry telemetry = new(store))
|
||||||
|
{
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"telemetry-recording",
|
||||||
|
Math.Max(100, options.Samples),
|
||||||
|
budgetMilliseconds: 1,
|
||||||
|
minimumOperationsPerSecond: 10_000,
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
telemetry.RecordHttp("browse", 200, 1);
|
||||||
|
telemetry.RecordUdp("contribution", "accepted", 1);
|
||||||
|
telemetry.RecordPairingLatency(2);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(61));
|
||||||
|
EphemeralStoreSnapshot? afterCoincidentExpiry = null;
|
||||||
|
measurements.Add(Measure(
|
||||||
|
"coincident-listing-attempt-expiry",
|
||||||
|
samples: 1,
|
||||||
|
budgetMilliseconds: 200,
|
||||||
|
minimumOperationsPerSecond: 0,
|
||||||
|
_ => afterCoincidentExpiry = store.GetSnapshot()));
|
||||||
|
if (afterCoincidentExpiry!.ActiveJoinAttempts != 0
|
||||||
|
|| afterCoincidentExpiry.ActiveListings != 0)
|
||||||
|
{
|
||||||
|
failures.Add("Coincident 60-second cleanup retained expired listings or join attempts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(90));
|
||||||
|
_ = store.GetSnapshot();
|
||||||
|
StoredListing recoveryListing = CreateVisibleListing(store, options.Listings + 1);
|
||||||
|
StoreResult<StoredJoinAttempt> recovered = store.CreateJoinAttempt(
|
||||||
|
CreateAttempt(options.Attempts + 2, recoveryListing));
|
||||||
|
bool recoverySucceeded = recovered.Succeeded;
|
||||||
|
if (!recoverySucceeded)
|
||||||
|
{
|
||||||
|
failures.Add($"Store did not recover after attempt expiry: {recovered.Code}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(151));
|
||||||
|
EphemeralStoreSnapshot final = store.GetSnapshot();
|
||||||
|
if (final.ActiveListings != 0
|
||||||
|
|| final.ActiveJoinAttempts != 0
|
||||||
|
|| final.ReplayMarkers != 0
|
||||||
|
|| final.IdempotencyEntries != 0
|
||||||
|
|| final.RetainedOutcomeReports != 0)
|
||||||
|
{
|
||||||
|
failures.Add("Expiry cleanup left active or retained state after every configured deadline.");
|
||||||
|
}
|
||||||
|
|
||||||
|
capacityPhaseTime.Stop();
|
||||||
|
process.Refresh();
|
||||||
|
double capacityPhaseCpuPercent = 100
|
||||||
|
* (process.TotalProcessorTime - cpuBefore).TotalSeconds
|
||||||
|
/ Math.Max(capacityPhaseTime.Elapsed.TotalSeconds * Environment.ProcessorCount, 0.000_001);
|
||||||
|
if (options.Profile == "candidate" && capacityPhaseCpuPercent > 70)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Capacity-phase CPU {capacityPhaseCpuPercent:F1}% exceeded the 70% candidate budget.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SoakEvidence soak = RunAcceleratedSoak(options, failures);
|
||||||
|
ManualClock restartClock = new();
|
||||||
|
EphemeralStoreSnapshot restarted = CreateStore(options, restartClock).GetSnapshot();
|
||||||
|
bool restartStartedEmpty = restarted.ActiveListings == 0
|
||||||
|
&& restarted.ActiveJoinAttempts == 0
|
||||||
|
&& restarted.ReplayMarkers == 0;
|
||||||
|
if (!restartStartedEmpty)
|
||||||
|
{
|
||||||
|
failures.Add("A restarted store did not begin empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (CapacityMeasurement measurement in measurements.Where(static item => !item.Passed))
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"{measurement.Operation} missed its budget: p95={measurement.P95Milliseconds:F3} ms, "
|
||||||
|
+ $"rate={measurement.OperationsPerSecond:F1}/s.");
|
||||||
|
}
|
||||||
|
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
process.Refresh();
|
||||||
|
long managedAfterCleanup = GC.GetTotalMemory(forceFullCollection: true);
|
||||||
|
long memoryBudget = 1_610_612_736;
|
||||||
|
if (process.PeakWorkingSet64 > memoryBudget)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Peak working set {process.PeakWorkingSet64} exceeded the 1.5 GiB profile budget.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Profile == "candidate" && Environment.ProcessorCount != 2)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Candidate evidence must expose exactly two CPUs; runtime exposed "
|
||||||
|
+ $"{Environment.ProcessorCount}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CapacityReport report = new()
|
||||||
|
{
|
||||||
|
SchemaVersion = 2,
|
||||||
|
EvidenceVersion = "v2",
|
||||||
|
GeneratedAt = DateTimeOffset.UtcNow,
|
||||||
|
Profile = options.Profile,
|
||||||
|
Runtime = new(
|
||||||
|
RuntimeInformation.FrameworkDescription,
|
||||||
|
RuntimeInformation.OSDescription,
|
||||||
|
Environment.OSVersion.VersionString,
|
||||||
|
RuntimeInformation.ProcessArchitecture.ToString(),
|
||||||
|
ReadCpuModel(),
|
||||||
|
Environment.ProcessorCount,
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_CPUSET") ?? "unrestricted",
|
||||||
|
ReadCgroupValue("/sys/fs/cgroup/cpu.max"),
|
||||||
|
ReadCgroupValue("/sys/fs/cgroup/memory.max"),
|
||||||
|
GCSettings.IsServerGC ? "server" : "workstation",
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_COMMIT") ?? "unrecorded",
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_TREE_STATE") ?? "unrecorded",
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_COMMAND") ?? "unrecorded",
|
||||||
|
Environment.GetEnvironmentVariable("RENDEZVOUS_EVIDENCE_IMAGE_DIGEST") ?? "not-containerized",
|
||||||
|
"fixed-sequences-random-identifiers",
|
||||||
|
capacityPhaseCpuPercent,
|
||||||
|
process.PeakWorkingSet64,
|
||||||
|
managedAfterCleanup),
|
||||||
|
Targets = new(
|
||||||
|
options.Listings,
|
||||||
|
options.Attempts,
|
||||||
|
200,
|
||||||
|
2_000,
|
||||||
|
200,
|
||||||
|
100,
|
||||||
|
70,
|
||||||
|
memoryBudget,
|
||||||
|
options.SoakCycles,
|
||||||
|
options.SoakSeconds),
|
||||||
|
Measurements = measurements,
|
||||||
|
State = new(
|
||||||
|
peak.ActiveListings,
|
||||||
|
peak.ActiveJoinAttempts,
|
||||||
|
peak.ReplayMarkers,
|
||||||
|
final.ActiveListings,
|
||||||
|
final.ActiveJoinAttempts,
|
||||||
|
final.ReplayMarkers,
|
||||||
|
final.ExpiryChurn,
|
||||||
|
final.MaintenanceSweeps,
|
||||||
|
soak.Cycles,
|
||||||
|
soak.Duration.TotalSeconds,
|
||||||
|
soak.PeakScheduledExpiryEntries,
|
||||||
|
soak.ManagedGrowthBytes,
|
||||||
|
soak.HandleGrowth,
|
||||||
|
restartStartedEmpty,
|
||||||
|
overloadWasTyped,
|
||||||
|
recoverySucceeded),
|
||||||
|
Failures = failures,
|
||||||
|
Passed = failures.Count == 0,
|
||||||
|
};
|
||||||
|
return Task.FromResult(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InMemoryEphemeralRendezvousStore CreateStore(
|
||||||
|
CapacityOptions options,
|
||||||
|
ManualClock clock) => new(
|
||||||
|
new EphemeralStoreOptions
|
||||||
|
{
|
||||||
|
MaxListings = options.Listings,
|
||||||
|
MaxPresenceBindings = options.Listings,
|
||||||
|
MaxJoinAttempts = options.Attempts,
|
||||||
|
MaxOutcomeReports = options.Attempts,
|
||||||
|
MaxIdempotencyEntries = options.Listings + options.Attempts + 1,
|
||||||
|
},
|
||||||
|
clock,
|
||||||
|
clock);
|
||||||
|
|
||||||
|
private static StoredListing CreateVisibleListing(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
int sequence)
|
||||||
|
{
|
||||||
|
string owner = $"publisher-{sequence}";
|
||||||
|
SecretFingerprint leaseFingerprint = new($"lease-{sequence}");
|
||||||
|
SecretFingerprint presenceFingerprint = new($"presence-{sequence}");
|
||||||
|
ListingDefinition definition = new()
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.NewGuid()),
|
||||||
|
LeaseId = new(Guid.NewGuid()),
|
||||||
|
Scope = Scope,
|
||||||
|
OwnerSubject = owner,
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = ProtocolVersion,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = $"Capacity host {sequence}",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
TrustMode = PublisherTrustMode.ManagedDedicated,
|
||||||
|
CurrentPlayers = 1,
|
||||||
|
MaximumPlayers = 8,
|
||||||
|
Metadata = new Dictionary<string, string>(StringComparer.Ordinal),
|
||||||
|
LeaseFingerprint = leaseFingerprint,
|
||||||
|
HostPresenceHandle = new(Guid.NewGuid()),
|
||||||
|
HostPresenceFingerprint = presenceFingerprint,
|
||||||
|
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
};
|
||||||
|
StoreResult<StoredListing> created = store.CreateListing(new(
|
||||||
|
$"register-{sequence}",
|
||||||
|
$"register-request-{sequence}",
|
||||||
|
definition));
|
||||||
|
RequireSuccess(created, "registration");
|
||||||
|
StoreResult<StoredListing> bound = store.BindHostPresence(new(
|
||||||
|
definition.HostPresenceHandle,
|
||||||
|
presenceFingerprint,
|
||||||
|
PublicEndpoint(10_000 + sequence % 50_000),
|
||||||
|
null));
|
||||||
|
RequireSuccess(bound, "host presence");
|
||||||
|
return bound.Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateJoinAttemptCommand CreateAttempt(int sequence, StoredListing listing) => new()
|
||||||
|
{
|
||||||
|
IdempotencyOwner = $"client-{sequence}",
|
||||||
|
IdempotencyKey = $"join-{sequence}",
|
||||||
|
RequestFingerprint = $"join-request-{sequence}",
|
||||||
|
ClientSubject = $"client-{sequence}",
|
||||||
|
AttemptId = new(Guid.NewGuid()),
|
||||||
|
MediationHandle = new(Guid.NewGuid()),
|
||||||
|
Scope = Scope,
|
||||||
|
ListingId = listing.Definition.ListingId,
|
||||||
|
ProtocolVersion = ProtocolVersion,
|
||||||
|
HostCapabilityFingerprint = new($"host-capability-{sequence}"),
|
||||||
|
ClientCapabilityFingerprint = new($"client-capability-{sequence}"),
|
||||||
|
ConnectionTicketFingerprint = new($"ticket-{sequence}"),
|
||||||
|
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static CapacityMeasurement MeasureConcurrentPunch(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
List<CreateJoinAttemptCommand> attempts,
|
||||||
|
int samples)
|
||||||
|
{
|
||||||
|
ConcurrentBag<double> latencies = [];
|
||||||
|
Stopwatch total = Stopwatch.StartNew();
|
||||||
|
Parallel.ForEach(
|
||||||
|
Enumerable.Range(0, samples),
|
||||||
|
new ParallelOptions { MaxDegreeOfParallelism = Math.Min(64, Environment.ProcessorCount * 4) },
|
||||||
|
index =>
|
||||||
|
{
|
||||||
|
CreateJoinAttemptCommand attempt = attempts[index];
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
RequireSuccess(store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Host,
|
||||||
|
attempt.HostCapabilityFingerprint,
|
||||||
|
PublicEndpoint(20_000 + index % 20_000),
|
||||||
|
null)), "host punch");
|
||||||
|
RequireSuccess(store.BindAttemptEndpoint(new(
|
||||||
|
attempt.MediationHandle,
|
||||||
|
AttemptPeerRole.Client,
|
||||||
|
attempt.ClientCapabilityFingerprint,
|
||||||
|
PublicEndpoint(40_000 + index % 20_000),
|
||||||
|
null)), "client punch");
|
||||||
|
RequireSuccess(store.ConsumeIntroduction(attempt.MediationHandle), "introduction");
|
||||||
|
latencies.Add(elapsed.Elapsed.TotalMilliseconds);
|
||||||
|
});
|
||||||
|
total.Stop();
|
||||||
|
return BuildMeasurement(
|
||||||
|
"simultaneous-punch-pairing",
|
||||||
|
latencies.ToArray(),
|
||||||
|
total.Elapsed,
|
||||||
|
budgetMilliseconds: 100,
|
||||||
|
minimumOperationsPerSecond: 2_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CapacityMeasurement Measure(
|
||||||
|
string operation,
|
||||||
|
int samples,
|
||||||
|
double budgetMilliseconds,
|
||||||
|
double minimumOperationsPerSecond,
|
||||||
|
Action<int> action)
|
||||||
|
{
|
||||||
|
double[] latencies = new double[samples];
|
||||||
|
Stopwatch total = Stopwatch.StartNew();
|
||||||
|
for (int index = 0; index < samples; index++)
|
||||||
|
{
|
||||||
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
action(index);
|
||||||
|
latencies[index] = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
total.Stop();
|
||||||
|
return BuildMeasurement(
|
||||||
|
operation,
|
||||||
|
latencies,
|
||||||
|
total.Elapsed,
|
||||||
|
budgetMilliseconds,
|
||||||
|
minimumOperationsPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CapacityMeasurement BuildMeasurement(
|
||||||
|
string operation,
|
||||||
|
double[] latencies,
|
||||||
|
TimeSpan elapsed,
|
||||||
|
double budgetMilliseconds,
|
||||||
|
double minimumOperationsPerSecond)
|
||||||
|
{
|
||||||
|
Array.Sort(latencies);
|
||||||
|
double operationsPerSecond = latencies.Length / Math.Max(elapsed.TotalSeconds, 0.000_001);
|
||||||
|
double p95 = Percentile(latencies, 0.95);
|
||||||
|
return new(
|
||||||
|
operation,
|
||||||
|
latencies.Length,
|
||||||
|
Percentile(latencies, 0.50),
|
||||||
|
p95,
|
||||||
|
Percentile(latencies, 0.99),
|
||||||
|
operationsPerSecond,
|
||||||
|
minimumOperationsPerSecond,
|
||||||
|
budgetMilliseconds,
|
||||||
|
p95 <= budgetMilliseconds && operationsPerSecond >= minimumOperationsPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Percentile(double[] sorted, double percentile)
|
||||||
|
{
|
||||||
|
int index = Math.Clamp((int)Math.Ceiling(sorted.Length * percentile) - 1, 0, sorted.Length - 1);
|
||||||
|
return sorted[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SoakEvidence RunAcceleratedSoak(
|
||||||
|
CapacityOptions options,
|
||||||
|
List<string> failures)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
long managedBefore = GC.GetTotalMemory(forceFullCollection: true);
|
||||||
|
int handlesBefore = Process.GetCurrentProcess().HandleCount;
|
||||||
|
ManualClock clock = new();
|
||||||
|
CapacityOptions soakOptions = options with { Listings = 100, Attempts = 100 };
|
||||||
|
InMemoryEphemeralRendezvousStore store = CreateStore(soakOptions, clock);
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
int cycle = 0;
|
||||||
|
int peakScheduledExpiryEntries = 0;
|
||||||
|
while (cycle < options.SoakCycles
|
||||||
|
|| elapsed.Elapsed < TimeSpan.FromSeconds(options.SoakSeconds))
|
||||||
|
{
|
||||||
|
StoredListing listing = CreateVisibleListing(store, cycle);
|
||||||
|
for (int refresh = 0; refresh < 10; refresh++)
|
||||||
|
{
|
||||||
|
clock.Advance(TimeSpan.FromTicks(1));
|
||||||
|
listing = RequireSuccess(store.RenewLease(new(
|
||||||
|
listing.Definition.ListingId,
|
||||||
|
listing.Definition.LeaseId,
|
||||||
|
listing.Definition.LeaseFingerprint,
|
||||||
|
listing.Definition.OwnerSubject,
|
||||||
|
listing.Version)), "soak lease refresh");
|
||||||
|
listing = RequireSuccess(store.BindHostPresence(new(
|
||||||
|
listing.Definition.HostPresenceHandle,
|
||||||
|
listing.Definition.HostPresenceFingerprint,
|
||||||
|
PublicEndpoint(10_000 + cycle % 50_000),
|
||||||
|
null)), "soak presence refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateJoinAttemptCommand attempt = CreateAttempt(cycle, listing);
|
||||||
|
RequireSuccess(store.CreateJoinAttempt(attempt), "soak join issuance");
|
||||||
|
RequireSuccess(store.ConsumeReplay(new("capacity-soak", $"replay-{cycle}")), "soak replay");
|
||||||
|
peakScheduledExpiryEntries = Math.Max(
|
||||||
|
peakScheduledExpiryEntries,
|
||||||
|
store.ScheduledExpiryEntryCount);
|
||||||
|
if (store.ScheduledExpiryEntryCount > 7)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"Mutable deadline refresh grew the expiry queue to "
|
||||||
|
+ $"{store.ScheduledExpiryEntryCount} entries for one lifecycle.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(151));
|
||||||
|
EphemeralStoreSnapshot snapshot = store.GetSnapshot();
|
||||||
|
if (snapshot.ActiveListings != 0
|
||||||
|
|| snapshot.ActiveJoinAttempts != 0
|
||||||
|
|| snapshot.ReplayMarkers != 0
|
||||||
|
|| snapshot.IdempotencyEntries != 0
|
||||||
|
|| snapshot.RetainedOutcomeReports != 0)
|
||||||
|
{
|
||||||
|
failures.Add($"Accelerated soak retained state after cycle {cycle}.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
cycle++;
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed.Stop();
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
long managedGrowth = GC.GetTotalMemory(forceFullCollection: true) - managedBefore;
|
||||||
|
int handleGrowth = Process.GetCurrentProcess().HandleCount - handlesBefore;
|
||||||
|
if (managedGrowth > 67_108_864)
|
||||||
|
{
|
||||||
|
failures.Add($"Soak retained {managedGrowth} managed bytes; budget is 64 MiB.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handleGrowth > 8)
|
||||||
|
{
|
||||||
|
failures.Add($"Soak retained {handleGrowth} process handles; budget is 8.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(cycle, elapsed.Elapsed, peakScheduledExpiryEntries, managedGrowth, handleGrowth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ObservedEndpoint PublicEndpoint(int port) =>
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.10", port);
|
||||||
|
|
||||||
|
private static T RequireSuccess<T>(StoreResult<T> result, string operation)
|
||||||
|
{
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{operation} failed with {result.Code}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadCpuModel()
|
||||||
|
{
|
||||||
|
const string cpuInfoPath = "/proc/cpuinfo";
|
||||||
|
if (!File.Exists(cpuInfoPath))
|
||||||
|
{
|
||||||
|
return "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
string? model = File.ReadLines(cpuInfoPath)
|
||||||
|
.FirstOrDefault(static line => line.StartsWith("model name", StringComparison.Ordinal));
|
||||||
|
int separator = model?.IndexOf(':') ?? -1;
|
||||||
|
return separator >= 0 ? model![(separator + 1)..].Trim() : "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadCgroupValue(string path) =>
|
||||||
|
File.Exists(path) ? File.ReadAllText(path).Trim() : "not-enforced";
|
||||||
|
|
||||||
|
private sealed class ManualClock : IWallClock, IMonotonicClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; private set; } = DateTimeOffset.UtcNow;
|
||||||
|
public TimeSpan Elapsed { get; private set; }
|
||||||
|
|
||||||
|
public void Advance(TimeSpan duration)
|
||||||
|
{
|
||||||
|
UtcNow += duration;
|
||||||
|
Elapsed += duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct SoakEvidence(
|
||||||
|
int Cycles,
|
||||||
|
TimeSpan Duration,
|
||||||
|
int PeakScheduledExpiryEntries,
|
||||||
|
long ManagedGrowthBytes,
|
||||||
|
int HandleGrowth);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<AssemblyName>FinalFactory.Rendezvous.Capacity</AssemblyName>
|
||||||
|
<RootNamespace>FinalFactory.Rendezvous.Capacity</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="../../src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Capacity;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
CapacityOptions options;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
options = CapacityOptions.Parse(args);
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(exception.Message);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
CapacityReport report = await CapacityRunner.RunAsync(options).ConfigureAwait(false);
|
||||||
|
string json = JsonSerializer.Serialize(report, JsonOptions);
|
||||||
|
Console.WriteLine(json);
|
||||||
|
if (options.OutputPath is not null)
|
||||||
|
{
|
||||||
|
string fullPath = Path.GetFullPath(options.OutputPath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||||
|
await File.WriteAllTextAsync(fullPath, json + Environment.NewLine).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return report.Passed ? 0 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0": {
|
||||||
|
"finalfactory.rendezvous.contracts": {
|
||||||
|
"type": "Project"
|
||||||
|
},
|
||||||
|
"finalfactory.rendezvous.server": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
|
||||||
|
"LiteNetLib": "[2.1.4, 2.1.4]",
|
||||||
|
"Microsoft.AspNetCore.OpenApi": "[10.0.9, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LiteNetLib": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.1.4, 2.1.4]",
|
||||||
|
"resolved": "2.1.4",
|
||||||
|
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
|
||||||
|
},
|
||||||
|
"Microsoft.AspNetCore.OpenApi": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[10.0.9, )",
|
||||||
|
"resolved": "10.0.9",
|
||||||
|
"contentHash": "1ihb8FO9cGgEK1/m3CTtT/SfnynwmiZib0W2pcDVj3KSWk/Sca4VOXEtaptKQc582zpFrzTFiwkGRCglt6H+WQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.OpenApi": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.OpenApi": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.7.5, )",
|
||||||
|
"resolved": "2.7.5",
|
||||||
|
"contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
using FinalFactory.Rendezvous.Client;
|
using FinalFactory.Rendezvous.Client;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
using FinalFactory.Rendezvous.Server.Abuse;
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
@@ -19,6 +22,49 @@ namespace FinalFactory.Rendezvous.Tests.Client;
|
|||||||
|
|
||||||
public sealed class RendezvousClientIntegrationTests
|
public sealed class RendezvousClientIntegrationTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RestartReturnsTypedUnavailabilityThenAllowsHostReregistration()
|
||||||
|
{
|
||||||
|
int port = ReserveTcpPort();
|
||||||
|
string address = $"http://127.0.0.1:{port}";
|
||||||
|
using HttpClient client = new() { BaseAddress = new Uri(address) };
|
||||||
|
RendezvousClientOptions noRetry = new()
|
||||||
|
{
|
||||||
|
MaximumSafeRetries = 0,
|
||||||
|
RequestTimeout = TimeSpan.FromSeconds(1),
|
||||||
|
};
|
||||||
|
ClientTestHost first = await ClientTestHost.StartAsync(address);
|
||||||
|
RendezvousPublisherClient publisher = new(client, noRetry);
|
||||||
|
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(100),
|
||||||
|
first.PublisherCredential);
|
||||||
|
PublishedSession initialSession = AssertSuccess(registered);
|
||||||
|
BindPresence(first, initialSession, 41_100);
|
||||||
|
RendezvousSessionBrowserClient browser = new(client, noRetry);
|
||||||
|
BrowseSessionsResponse beforeRestart = AssertSuccess(await browser.BrowseAsync(BrowseRequest()));
|
||||||
|
Assert.Equal(initialSession.ListingId, Assert.Single(beforeRestart.Items).ListingId);
|
||||||
|
Stopwatch restart = Stopwatch.StartNew();
|
||||||
|
await first.DisposeAsync();
|
||||||
|
|
||||||
|
RendezvousClientResult<BrowseSessionsResponse> unavailable = await browser.BrowseAsync(BrowseRequest());
|
||||||
|
Assert.False(unavailable.IsSuccess);
|
||||||
|
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, unavailable.Error);
|
||||||
|
|
||||||
|
await using ClientTestHost second = await ClientTestHost.StartAsync(address);
|
||||||
|
RendezvousClientResult<PublishedSession> reregistered = await publisher.RegisterAsync(
|
||||||
|
CreateRegistration(101),
|
||||||
|
second.PublisherCredential);
|
||||||
|
PublishedSession replacementSession = AssertSuccess(reregistered);
|
||||||
|
Assert.NotEqual(initialSession.ListingId, replacementSession.ListingId);
|
||||||
|
BindPresence(second, replacementSession, 41_101);
|
||||||
|
BrowseSessionsResponse afterRestart = AssertSuccess(await browser.BrowseAsync(BrowseRequest()));
|
||||||
|
Assert.Equal(replacementSession.ListingId, Assert.Single(afterRestart.Items).ListingId);
|
||||||
|
Assert.DoesNotContain(afterRestart.Items, item => item.ListingId == initialSession.ListingId);
|
||||||
|
Assert.True(
|
||||||
|
restart.Elapsed < TimeSpan.FromSeconds(5),
|
||||||
|
$"Local restart and host re-registration took {restart.Elapsed}.");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
|
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
|
||||||
{
|
{
|
||||||
@@ -102,6 +148,27 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
return Assert.IsAssignableFrom<T>(result.Value);
|
return Assert.IsAssignableFrom<T>(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void BindPresence(ClientTestHost host, PublishedSession session, int port)
|
||||||
|
{
|
||||||
|
Assert.True(host.Capabilities.TryFingerprint(
|
||||||
|
session.HostPresenceCapability,
|
||||||
|
out SecretFingerprint fingerprint));
|
||||||
|
Assert.Equal(StoreResultCode.Success, host.Store.BindHostPresence(new(
|
||||||
|
session.HostPresenceHandle,
|
||||||
|
fingerprint,
|
||||||
|
new(AddressFamilyKind.Ipv4, "203.0.113.80", port),
|
||||||
|
null)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BrowseSessionsRequest BrowseRequest() => new()
|
||||||
|
{
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
PageSize = 10,
|
||||||
|
};
|
||||||
|
|
||||||
private static RegisterSessionRequest CreateRegistration(int index) => new()
|
private static RegisterSessionRequest CreateRegistration(int index) => new()
|
||||||
{
|
{
|
||||||
IdempotencyKey = $"sdk-integration-{index}",
|
IdempotencyKey = $"sdk-integration-{index}",
|
||||||
@@ -139,7 +206,7 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
internal EphemeralCapabilityIssuer Capabilities { get; }
|
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||||
internal string PublisherCredential { get; }
|
internal string PublisherCredential { get; }
|
||||||
|
|
||||||
internal static async Task<ClientTestHost> StartAsync()
|
internal static async Task<ClientTestHost> StartAsync(string? bindAddress = null)
|
||||||
{
|
{
|
||||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
EphemeralStoreOptions stateOptions = new();
|
EphemeralStoreOptions stateOptions = new();
|
||||||
@@ -153,7 +220,7 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
builder.WebHost.UseUrls(bindAddress ?? "http://127.0.0.1:0");
|
||||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||||
ContractJson.Configure(options.SerializerOptions));
|
ContractJson.Configure(options.SerializerOptions));
|
||||||
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||||
@@ -180,10 +247,10 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
app.MapRendezvousContractEndpoints();
|
app.MapRendezvousContractEndpoints();
|
||||||
await app.StartAsync();
|
await app.StartAsync();
|
||||||
IServer server = app.Services.GetRequiredService<IServer>();
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
string serviceAddress = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
return new(
|
return new(
|
||||||
app,
|
app,
|
||||||
new HttpClient { BaseAddress = new Uri(address) },
|
new HttpClient { BaseAddress = new Uri(serviceAddress) },
|
||||||
store,
|
store,
|
||||||
capabilities,
|
capabilities,
|
||||||
credential);
|
credential);
|
||||||
@@ -196,4 +263,13 @@ public sealed class RendezvousClientIntegrationTests
|
|||||||
await _application.DisposeAsync();
|
await _application.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int ReserveTcpPort()
|
||||||
|
{
|
||||||
|
TcpListener listener = new(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,69 @@
|
|||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||||
|
|
||||||
internal static class ContractTestFiles
|
internal static class ContractTestFiles
|
||||||
{
|
{
|
||||||
public static string Read(string fileName) => File
|
public static string Read(string fileName) => File
|
||||||
.ReadAllText(Path.Combine(Directory, fileName))
|
.ReadAllText(Path.Combine(DirectoryFor(fileName), fileName))
|
||||||
.TrimEnd('\r', '\n');
|
.TrimEnd('\r', '\n');
|
||||||
|
|
||||||
public static string Directory
|
public static string Directory
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return VersionedDirectory(Property("RendezvousMajorVersion"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string OpenApiDocument
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
string httpVersion = Property("HttpContractVersion");
|
||||||
|
return Path.Combine(RepositoryRoot, $"docs/api/rendezvous-v{httpVersion}.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DirectoryFor(string fileName)
|
||||||
|
{
|
||||||
|
string property = fileName switch
|
||||||
|
{
|
||||||
|
"client-public-api.txt" or "contracts-public-api.txt" => "RendezvousMajorVersion",
|
||||||
|
"connection-ticket.json" => "ConnectionTicketFormatVersion",
|
||||||
|
_ when fileName.EndsWith(".hex", StringComparison.Ordinal) => "UdpContractVersion",
|
||||||
|
_ when fileName.EndsWith(".json", StringComparison.Ordinal) => "HttpContractVersion",
|
||||||
|
_ => throw new InvalidOperationException($"No contract version dimension maps {fileName}."),
|
||||||
|
};
|
||||||
|
return VersionedDirectory(Property(property));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string VersionedDirectory(string version) => Path.Combine(
|
||||||
|
RepositoryRoot,
|
||||||
|
$"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v{version}");
|
||||||
|
|
||||||
|
private static string Property(string name)
|
||||||
|
{
|
||||||
|
XDocument versions = XDocument.Load(Path.Combine(RepositoryRoot, "eng/Versions.props"));
|
||||||
|
return versions.Descendants(name).Single().Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RepositoryRoot
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
while (directory is not null)
|
while (directory is not null)
|
||||||
{
|
{
|
||||||
string solution = Path.Combine(directory.FullName, "Rendezvous.slnx");
|
if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
|
||||||
if (File.Exists(solution))
|
|
||||||
{
|
{
|
||||||
return Path.Combine(
|
return directory.FullName;
|
||||||
directory.FullName,
|
|
||||||
"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
directory = directory.Parent;
|
directory = directory.Parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new DirectoryNotFoundException("Could not locate contract test data.");
|
throw new DirectoryNotFoundException("Could not locate repository root.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
"/v1/join-attempts",
|
"/v1/join-attempts",
|
||||||
"/v1/join-attempts/{attemptId}",
|
"/v1/join-attempts/{attemptId}",
|
||||||
"/v1/join-attempts/{attemptId}/outcome",
|
"/v1/join-attempts/{attemptId}/outcome",
|
||||||
|
"/v1/operator/drain",
|
||||||
|
"/v1/operator/keys/revoke",
|
||||||
|
"/v1/operator/listings/revoke",
|
||||||
|
"/v1/operator/principals/revoke",
|
||||||
|
"/v1/operator/status",
|
||||||
"/v1/sessions",
|
"/v1/sessions",
|
||||||
"/v1/sessions/{listingId}",
|
"/v1/sessions/{listingId}",
|
||||||
"/v1/sessions/{listingId}/join-attempts",
|
"/v1/sessions/{listingId}/join-attempts",
|
||||||
@@ -35,12 +40,10 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
];
|
];
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GeneratedOpenApiContainsTheFrozenV1Surface()
|
public void GeneratedOpenApiContainsTheFrozenVersionedSurface()
|
||||||
{
|
{
|
||||||
string path = Path.Combine(
|
using JsonDocument document = JsonDocument.Parse(
|
||||||
ContractTestFiles.Directory,
|
File.ReadAllText(ContractTestFiles.OpenApiDocument));
|
||||||
"../../../../../docs/api/rendezvous-v1.json");
|
|
||||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(Path.GetFullPath(path)));
|
|
||||||
JsonElement root = document.RootElement;
|
JsonElement root = document.RootElement;
|
||||||
|
|
||||||
Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
|
Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
|
||||||
@@ -104,6 +107,11 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
"X-Rendezvous-Client-Punch-Capability",
|
"X-Rendezvous-Client-Punch-Capability",
|
||||||
attemptCapability.GetProperty("name").GetString());
|
attemptCapability.GetProperty("name").GetString());
|
||||||
|
JsonElement operatorBearer = root.GetProperty("components")
|
||||||
|
.GetProperty("securitySchemes")
|
||||||
|
.GetProperty("OperatorBearer");
|
||||||
|
Assert.Equal("http", operatorBearer.GetProperty("type").GetString());
|
||||||
|
Assert.Equal("bearer", operatorBearer.GetProperty("scheme").GetString());
|
||||||
(string Path, string Method)[] publisherOperations =
|
(string Path, string Method)[] publisherOperations =
|
||||||
[
|
[
|
||||||
("/v1/sessions", "post"),
|
("/v1/sessions", "post"),
|
||||||
@@ -120,6 +128,23 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
|
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(string Path, string Method)[] operatorOperations =
|
||||||
|
[
|
||||||
|
("/v1/operator/status", "get"),
|
||||||
|
("/v1/operator/listings/revoke", "post"),
|
||||||
|
("/v1/operator/principals/revoke", "post"),
|
||||||
|
("/v1/operator/keys/revoke", "post"),
|
||||||
|
("/v1/operator/drain", "post"),
|
||||||
|
];
|
||||||
|
foreach ((string operationPath, string method) in operatorOperations)
|
||||||
|
{
|
||||||
|
JsonElement security = root.GetProperty("paths")
|
||||||
|
.GetProperty(operationPath)
|
||||||
|
.GetProperty(method)
|
||||||
|
.GetProperty("security");
|
||||||
|
Assert.True(security[0].TryGetProperty("OperatorBearer", out _));
|
||||||
|
}
|
||||||
|
|
||||||
JsonElement cancelParameters = root.GetProperty("paths")
|
JsonElement cancelParameters = root.GetProperty("paths")
|
||||||
.GetProperty("/v1/join-attempts/{attemptId}")
|
.GetProperty("/v1/join-attempts/{attemptId}")
|
||||||
.GetProperty("delete")
|
.GetProperty("delete")
|
||||||
@@ -157,6 +182,15 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
static item => item.Name is "get" or "post" or "put" or "delete"))
|
static item => item.Name is "get" or "post" or "put" or "delete"))
|
||||||
{
|
{
|
||||||
JsonElement responses = operation.Value.GetProperty("responses");
|
JsonElement responses = operation.Value.GetProperty("responses");
|
||||||
|
foreach (JsonProperty response in responses.EnumerateObject())
|
||||||
|
{
|
||||||
|
JsonElement correlation = response.Value.GetProperty("headers")
|
||||||
|
.GetProperty("X-Rendezvous-Correlation-ID");
|
||||||
|
Assert.Equal(
|
||||||
|
"string",
|
||||||
|
correlation.GetProperty("schema").GetProperty("type").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
if (!responses.TryGetProperty("429", out JsonElement overloaded))
|
if (!responses.TryGetProperty("429", out JsonElement overloaded))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -171,7 +205,7 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.Equal(12, overloadContracts);
|
Assert.Equal(17, overloadContracts);
|
||||||
(string Path, string Method)[] bodyOperations =
|
(string Path, string Method)[] bodyOperations =
|
||||||
[
|
[
|
||||||
("/v1/sessions", "post"),
|
("/v1/sessions", "post"),
|
||||||
@@ -180,6 +214,10 @@ public sealed class OpenApiCompatibilityTests
|
|||||||
("/v1/sessions/{listingId}", "delete"),
|
("/v1/sessions/{listingId}", "delete"),
|
||||||
("/v1/join-attempts", "post"),
|
("/v1/join-attempts", "post"),
|
||||||
("/v1/join-attempts/{attemptId}/outcome", "post"),
|
("/v1/join-attempts/{attemptId}/outcome", "post"),
|
||||||
|
("/v1/operator/listings/revoke", "post"),
|
||||||
|
("/v1/operator/principals/revoke", "post"),
|
||||||
|
("/v1/operator/keys/revoke", "post"),
|
||||||
|
("/v1/operator/drain", "post"),
|
||||||
];
|
];
|
||||||
foreach ((string operationPath, string method) in bodyOperations)
|
foreach ((string operationPath, string method) in bodyOperations)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using FinalFactory.Rendezvous.Client;
|
using FinalFactory.Rendezvous.Client;
|
||||||
using FinalFactory.Rendezvous.Contracts;
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
@@ -22,6 +23,20 @@ public sealed class TraversalTokenCodecTests
|
|||||||
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
|
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionTicketMatchesTheVersionedV1Vector()
|
||||||
|
{
|
||||||
|
using JsonDocument vector = JsonDocument.Parse(ContractTestFiles.Read("connection-ticket.json"));
|
||||||
|
JsonElement root = vector.RootElement;
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse(root.GetProperty("attemptId").GetString()!));
|
||||||
|
string authenticator = root.GetProperty("derivedAuthenticator").GetString()!;
|
||||||
|
|
||||||
|
string ticket = NatIntroductionTokenCodec.Encode(attemptId, authenticator);
|
||||||
|
|
||||||
|
Assert.Equal(root.GetProperty("connectionTicket").GetString(), ticket);
|
||||||
|
Assert.Equal(root.GetProperty("digest").GetString(), NatIntroductionTokenCodec.ComputeDigest(ticket));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
|
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
using FinalFactory.Rendezvous.Server.Deployment;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Deployment;
|
||||||
|
|
||||||
|
public sealed class DeploymentOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ProductionConfigurationAcceptsExplicitPublicEndpointsAndTrustBoundary()
|
||||||
|
{
|
||||||
|
DeploymentOptions options = ValidOptions();
|
||||||
|
AbuseProtectionOptions abuse = new()
|
||||||
|
{
|
||||||
|
TrustedProxyAddresses = ["192.0.2.10"],
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<string> errors = options.ValidateProduction(
|
||||||
|
abuse,
|
||||||
|
"rendezvous.finalfactory.at");
|
||||||
|
|
||||||
|
Assert.Empty(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductionConfigurationRejectsUnsafeAndAmbiguousDefaults()
|
||||||
|
{
|
||||||
|
DeploymentOptions options = new()
|
||||||
|
{
|
||||||
|
SingleActiveInstance = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<string> errors = options.ValidateProduction(
|
||||||
|
new AbuseProtectionOptions(),
|
||||||
|
"*");
|
||||||
|
|
||||||
|
Assert.Contains(errors, error => error.Contains("SingleActiveInstance", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("PublicHttpBaseUrl", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("PublicUdpHost", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("TrustedProxyAddresses", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("AllowedHosts", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("http://rendezvous.example.com/")]
|
||||||
|
[InlineData("https://user@example.com/")]
|
||||||
|
[InlineData("https://rendezvous.example.com/path")]
|
||||||
|
[InlineData("https://localhost/")]
|
||||||
|
[InlineData("https://10.0.0.1/")]
|
||||||
|
[InlineData("https://192.0.2.10/")]
|
||||||
|
[InlineData("https://[::ffff:10.0.0.1]/")]
|
||||||
|
[InlineData("https://[ff02::1]/")]
|
||||||
|
[InlineData("https://rendezvous.invalid/")]
|
||||||
|
public void ProductionConfigurationRejectsUnsafeHttpEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
DeploymentOptions options = ValidOptions() with { PublicHttpBaseUrl = endpoint };
|
||||||
|
|
||||||
|
IReadOnlyList<string> errors = options.ValidateProduction(
|
||||||
|
new AbuseProtectionOptions { TrustedProxyAddresses = ["192.0.2.10"] },
|
||||||
|
"rendezvous.finalfactory.at");
|
||||||
|
|
||||||
|
Assert.Contains(errors, error => error.Contains("PublicHttpBaseUrl", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("0.1.2.3")]
|
||||||
|
[InlineData("100.64.0.1")]
|
||||||
|
[InlineData("192.0.2.1")]
|
||||||
|
[InlineData("198.18.0.1")]
|
||||||
|
[InlineData("198.51.100.1")]
|
||||||
|
[InlineData("203.0.113.1")]
|
||||||
|
[InlineData("224.0.0.1")]
|
||||||
|
[InlineData("255.255.255.255")]
|
||||||
|
[InlineData("::ffff:192.168.1.1")]
|
||||||
|
[InlineData("2001:db8::1")]
|
||||||
|
[InlineData("ff02::1")]
|
||||||
|
[InlineData("rendezvous.example.com")]
|
||||||
|
[InlineData("rendezvous.home.arpa")]
|
||||||
|
[InlineData("rendezvous.alt")]
|
||||||
|
[InlineData("service.test")]
|
||||||
|
public void ProductionConfigurationRejectsNonPublicUdpEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
DeploymentOptions options = ValidOptions() with { PublicUdpHost = endpoint };
|
||||||
|
|
||||||
|
IReadOnlyList<string> errors = options.ValidateProduction(
|
||||||
|
new AbuseProtectionOptions { TrustedProxyAddresses = ["192.0.2.10"] },
|
||||||
|
"rendezvous.finalfactory.at");
|
||||||
|
|
||||||
|
Assert.Contains(errors, error => error.Contains("PublicUdpHost", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsolatedSmokeTestRequiresExplicitPrivateEndpointOverride()
|
||||||
|
{
|
||||||
|
DeploymentOptions options = ValidOptions() with
|
||||||
|
{
|
||||||
|
PublicHttpBaseUrl = "https://localhost/",
|
||||||
|
PublicUdpHost = "127.0.0.1",
|
||||||
|
AllowPrivatePublicEndpoints = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<string> errors = options.ValidateProduction(
|
||||||
|
new AbuseProtectionOptions { TrustedProxyAddresses = ["127.0.0.1"] },
|
||||||
|
"localhost");
|
||||||
|
|
||||||
|
Assert.Empty(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductionConfigurationRejectsInvalidPortsDeadlinesAndMismatchedHostFilter()
|
||||||
|
{
|
||||||
|
DeploymentOptions options = ValidOptions() with
|
||||||
|
{
|
||||||
|
PublicUdpPort = 0,
|
||||||
|
DrainDeadlineSeconds = 31,
|
||||||
|
MinimumDrainSeconds = 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<string> errors = options.ValidateProduction(
|
||||||
|
new AbuseProtectionOptions { TrustedProxyAddresses = ["192.0.2.10"] },
|
||||||
|
"different.finalfactory.at");
|
||||||
|
|
||||||
|
Assert.Contains(errors, error => error.Contains("PublicUdpPort", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("DrainDeadlineSeconds", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("MinimumDrainSeconds", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(errors, error => error.Contains("exact host", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeploymentOptions ValidOptions() => new()
|
||||||
|
{
|
||||||
|
PublicHttpBaseUrl = "https://rendezvous.finalfactory.at/",
|
||||||
|
PublicUdpHost = "rendezvous-udp.finalfactory.at",
|
||||||
|
PublicUdpPort = 9050,
|
||||||
|
DrainDeadlineSeconds = 10,
|
||||||
|
MinimumDrainSeconds = 1,
|
||||||
|
SingleActiveInstance = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using FinalFactory.Rendezvous.Server.Deployment;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Deployment;
|
||||||
|
|
||||||
|
public sealed class GracefulDrainServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownRejectsNewWorkAndClearsStateAfterBoundedAttemptDeadline()
|
||||||
|
{
|
||||||
|
EphemeralStoreOptions stateOptions = new()
|
||||||
|
{
|
||||||
|
GracefulDrainLifetime = TimeSpan.FromSeconds(1),
|
||||||
|
};
|
||||||
|
EphemeralStateFixture fixture = new(stateOptions);
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
StoreResult<StoredJoinAttempt> attempt = fixture.Store.CreateJoinAttempt(
|
||||||
|
fixture.AttemptCommand(listing));
|
||||||
|
Assert.True(attempt.Succeeded);
|
||||||
|
using FakeApplicationLifetime lifetime = new();
|
||||||
|
using GracefulDrainService service = CreateService(fixture.Store, lifetime);
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Task stopping = Task.Run(lifetime.StopApplication);
|
||||||
|
await WaitUntilAsync(() => fixture.Store.IsDraining, TimeSpan.FromSeconds(1));
|
||||||
|
StoreResult<StoredListing> rejected = fixture.Store.CreateListing(fixture.ListingCommand());
|
||||||
|
long sweepsAfterAdmissionCheck = fixture.Store.MaintenanceSweepCount;
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
await stopping;
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(StoreResultCode.Draining, rejected.Code);
|
||||||
|
Assert.Equal(sweepsAfterAdmissionCheck, fixture.Store.MaintenanceSweepCount);
|
||||||
|
Assert.InRange(elapsed.Elapsed, TimeSpan.FromMilliseconds(850), TimeSpan.FromSeconds(2));
|
||||||
|
Assert.False(fixture.Store.IsAvailable);
|
||||||
|
Assert.Equal(0, fixture.Store.GetSnapshot().ActiveJoinAttempts);
|
||||||
|
Assert.Equal(0, fixture.Store.GetSnapshot().ActiveListings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownWithoutAttemptsStopsAfterTheConfiguredMinimumOnly()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new(new EphemeralStoreOptions
|
||||||
|
{
|
||||||
|
GracefulDrainLifetime = TimeSpan.FromSeconds(2),
|
||||||
|
});
|
||||||
|
fixture.CreateVisibleListing(out _);
|
||||||
|
using FakeApplicationLifetime lifetime = new();
|
||||||
|
DeploymentOptions options = new()
|
||||||
|
{
|
||||||
|
DrainDeadlineSeconds = 2,
|
||||||
|
MinimumDrainSeconds = 0,
|
||||||
|
};
|
||||||
|
using GracefulDrainService service = new(
|
||||||
|
fixture.Store,
|
||||||
|
lifetime,
|
||||||
|
Options.Create(options),
|
||||||
|
NullLogger<GracefulDrainService>.Instance);
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(elapsed.Elapsed < TimeSpan.FromMilliseconds(500));
|
||||||
|
Assert.False(fixture.Store.IsAvailable);
|
||||||
|
Assert.Equal(0, fixture.Store.GetSnapshot().ActiveListings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownStopsWhenTheLastAttemptExpiresWithoutAFullStateSweep()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new(new EphemeralStoreOptions
|
||||||
|
{
|
||||||
|
JoinAttemptLifetime = TimeSpan.FromMilliseconds(100),
|
||||||
|
ConnectionTicketLifetime = TimeSpan.FromMilliseconds(50),
|
||||||
|
GracefulDrainLifetime = TimeSpan.FromSeconds(2),
|
||||||
|
});
|
||||||
|
StoredListing listing = fixture.CreateVisibleListing(out _);
|
||||||
|
Assert.True(fixture.Store.CreateJoinAttempt(fixture.AttemptCommand(listing)).Succeeded);
|
||||||
|
using FakeApplicationLifetime lifetime = new();
|
||||||
|
using GracefulDrainService service = new(
|
||||||
|
fixture.Store,
|
||||||
|
lifetime,
|
||||||
|
Options.Create(new DeploymentOptions
|
||||||
|
{
|
||||||
|
DrainDeadlineSeconds = 2,
|
||||||
|
MinimumDrainSeconds = 0,
|
||||||
|
}),
|
||||||
|
NullLogger<GracefulDrainService>.Instance);
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Task stopping = Task.Run(lifetime.StopApplication);
|
||||||
|
await WaitUntilAsync(() => fixture.Store.IsDraining, TimeSpan.FromSeconds(1));
|
||||||
|
long sweepsBeforeExpiry = fixture.Store.MaintenanceSweepCount;
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromMilliseconds(150));
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(1));
|
||||||
|
await stopping.WaitAsync(timeout.Token);
|
||||||
|
|
||||||
|
Assert.Equal(sweepsBeforeExpiry, fixture.Store.MaintenanceSweepCount);
|
||||||
|
Assert.False(fixture.Store.IsAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GracefulDrainService CreateService(
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
IHostApplicationLifetime lifetime) => new(
|
||||||
|
store,
|
||||||
|
lifetime,
|
||||||
|
Options.Create(new DeploymentOptions
|
||||||
|
{
|
||||||
|
DrainDeadlineSeconds = 1,
|
||||||
|
MinimumDrainSeconds = 0,
|
||||||
|
}),
|
||||||
|
NullLogger<GracefulDrainService>.Instance);
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> predicate, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
while (!predicate())
|
||||||
|
{
|
||||||
|
Assert.True(elapsed.Elapsed < timeout, "The service did not enter drain in time.");
|
||||||
|
await Task.Delay(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeApplicationLifetime : IHostApplicationLifetime, IDisposable
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _started = new();
|
||||||
|
private readonly CancellationTokenSource _stopping = new();
|
||||||
|
private readonly CancellationTokenSource _stopped = new();
|
||||||
|
|
||||||
|
public CancellationToken ApplicationStarted => _started.Token;
|
||||||
|
public CancellationToken ApplicationStopping => _stopping.Token;
|
||||||
|
public CancellationToken ApplicationStopped => _stopped.Token;
|
||||||
|
|
||||||
|
public void StopApplication() => _stopping.Cancel();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_started.Dispose();
|
||||||
|
_stopping.Dispose();
|
||||||
|
_stopped.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Deployment;
|
||||||
|
|
||||||
|
public sealed class ProductionProcessTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SigtermDrainsThenReleasesHttpAndUdpSockets()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int httpPort = ReserveTcpPort();
|
||||||
|
int udpPort = ReserveUdpPort();
|
||||||
|
string secretPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"rendezvous-process-secret-{Guid.NewGuid():N}");
|
||||||
|
await File.WriteAllBytesAsync(secretPath, RandomNumberGenerator.GetBytes(32));
|
||||||
|
Process? process = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ProcessStartInfo startInfo = CreateStartInfo(httpPort, udpPort, secretPath);
|
||||||
|
process = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException("The production server process did not start.");
|
||||||
|
Task<string> standardOutput = process.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> standardError = process.StandardError.ReadToEndAsync();
|
||||||
|
|
||||||
|
await WaitForReadyAsync(httpPort, process, TimeSpan.FromSeconds(10));
|
||||||
|
AssertUdpPortIsBound(udpPort);
|
||||||
|
|
||||||
|
Stopwatch shutdown = Stopwatch.StartNew();
|
||||||
|
ProcessStartInfo signalInfo = new()
|
||||||
|
{
|
||||||
|
FileName = "/bin/kill",
|
||||||
|
UseShellExecute = false,
|
||||||
|
ArgumentList =
|
||||||
|
{
|
||||||
|
"-TERM",
|
||||||
|
process.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
using (Process signal = Process.Start(signalInfo)
|
||||||
|
?? throw new InvalidOperationException("Could not send SIGTERM."))
|
||||||
|
{
|
||||||
|
await signal.WaitForExitAsync();
|
||||||
|
Assert.Equal(0, signal.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(6));
|
||||||
|
await process.WaitForExitAsync(timeout.Token);
|
||||||
|
string output = await standardOutput;
|
||||||
|
string error = await standardError;
|
||||||
|
Assert.True(
|
||||||
|
process.ExitCode == 0,
|
||||||
|
$"Server exited with {process.ExitCode}. stdout: {output} stderr: {error}");
|
||||||
|
Assert.InRange(shutdown.Elapsed, TimeSpan.FromMilliseconds(700), TimeSpan.FromSeconds(5));
|
||||||
|
AssertTcpPortIsReleased(httpPort);
|
||||||
|
AssertUdpPortIsReleased(udpPort);
|
||||||
|
|
||||||
|
process.Dispose();
|
||||||
|
process = null;
|
||||||
|
Stopwatch replacementReady = Stopwatch.StartNew();
|
||||||
|
ProcessStartInfo replacementInfo = CreateStartInfo(httpPort, udpPort, secretPath);
|
||||||
|
process = Process.Start(replacementInfo)
|
||||||
|
?? throw new InvalidOperationException("The replacement production process did not start.");
|
||||||
|
Task<string> replacementOutput = process.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> replacementError = process.StandardError.ReadToEndAsync();
|
||||||
|
await WaitForReadyAsync(httpPort, process, TimeSpan.FromSeconds(15));
|
||||||
|
Assert.True(
|
||||||
|
replacementReady.Elapsed < TimeSpan.FromSeconds(15),
|
||||||
|
$"Replacement readiness took {replacementReady.Elapsed}.");
|
||||||
|
AssertUdpPortIsBound(udpPort);
|
||||||
|
|
||||||
|
await SendSigtermAsync(process);
|
||||||
|
using CancellationTokenSource replacementTimeout = new(TimeSpan.FromSeconds(6));
|
||||||
|
await process.WaitForExitAsync(replacementTimeout.Token);
|
||||||
|
string replacementFinalOutput = await replacementOutput;
|
||||||
|
string replacementFinalError = await replacementError;
|
||||||
|
Assert.True(
|
||||||
|
process.ExitCode == 0,
|
||||||
|
$"Replacement exited with {process.ExitCode}. "
|
||||||
|
+ $"stdout: {replacementFinalOutput} stderr: {replacementFinalError}");
|
||||||
|
AssertTcpPortIsReleased(httpPort);
|
||||||
|
AssertUdpPortIsReleased(udpPort);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (process is not null)
|
||||||
|
{
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
await process.WaitForExitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(secretPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProductionTransportSoakKeepsHandlesMemoryAndSocketsBounded()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int httpPort = ReserveTcpPort();
|
||||||
|
int udpPort = ReserveUdpPort();
|
||||||
|
string secretPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"rendezvous-transport-soak-secret-{Guid.NewGuid():N}");
|
||||||
|
await File.WriteAllBytesAsync(secretPath, RandomNumberGenerator.GetBytes(32));
|
||||||
|
Process? process = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process = Process.Start(CreateStartInfo(httpPort, udpPort, secretPath))
|
||||||
|
?? throw new InvalidOperationException("The production soak process did not start.");
|
||||||
|
Task<string> standardOutput = process.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> standardError = process.StandardError.ReadToEndAsync();
|
||||||
|
await WaitForReadyAsync(httpPort, process, TimeSpan.FromSeconds(10));
|
||||||
|
process.Refresh();
|
||||||
|
int baselineHandles = process.HandleCount;
|
||||||
|
long baselineWorkingSet = process.WorkingSet64;
|
||||||
|
int peakHandles = baselineHandles;
|
||||||
|
byte[] invalidDatagram = RandomNumberGenerator.GetBytes(64);
|
||||||
|
IPEndPoint udpEndpoint = new(IPAddress.Loopback, udpPort);
|
||||||
|
using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(1) };
|
||||||
|
using UdpClient udp = new();
|
||||||
|
Stopwatch soak = Stopwatch.StartNew();
|
||||||
|
int cycles = 0;
|
||||||
|
int accepted = 0;
|
||||||
|
int shed = 0;
|
||||||
|
while (soak.Elapsed < TimeSpan.FromSeconds(10))
|
||||||
|
{
|
||||||
|
using HttpResponseMessage response = await client.GetAsync(
|
||||||
|
$"http://127.0.0.1:{httpPort}/health/live");
|
||||||
|
if (response.StatusCode == HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
accepted++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
|
||||||
|
shed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await udp.SendAsync(invalidDatagram, udpEndpoint);
|
||||||
|
cycles++;
|
||||||
|
if (cycles % 100 == 0)
|
||||||
|
{
|
||||||
|
process.Refresh();
|
||||||
|
peakHandles = Math.Max(peakHandles, process.HandleCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(cycles >= 100, $"Transport soak completed only {cycles} cycles.");
|
||||||
|
Assert.True(accepted > 0, "Transport soak never admitted a health request.");
|
||||||
|
Assert.True(shed > 0, "Transport soak never exercised typed HTTP load shedding.");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
|
using (HttpResponseMessage recovered = await client.GetAsync(
|
||||||
|
$"http://127.0.0.1:{httpPort}/health/live"))
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.OK, recovered.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.Refresh();
|
||||||
|
Assert.InRange(peakHandles, 0, baselineHandles + 32);
|
||||||
|
Assert.InRange(process.HandleCount, 0, baselineHandles + 16);
|
||||||
|
Assert.InRange(process.WorkingSet64, 0, baselineWorkingSet + 67_108_864);
|
||||||
|
AssertUdpPortIsBound(udpPort);
|
||||||
|
|
||||||
|
await SendSigtermAsync(process);
|
||||||
|
using CancellationTokenSource shutdownTimeout = new(TimeSpan.FromSeconds(6));
|
||||||
|
await process.WaitForExitAsync(shutdownTimeout.Token);
|
||||||
|
string output = await standardOutput;
|
||||||
|
string error = await standardError;
|
||||||
|
Assert.True(
|
||||||
|
process.ExitCode == 0,
|
||||||
|
$"Transport soak process failed. stdout: {output} stderr: {error}");
|
||||||
|
AssertTcpPortIsReleased(httpPort);
|
||||||
|
AssertUdpPortIsReleased(udpPort);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await StopProcessTreeAsync(process);
|
||||||
|
File.Delete(secretPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DocumentedSmokeScriptReachesHttpAndAuthenticatedUdpFlow()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string root = RepositoryRoot();
|
||||||
|
int httpPort = ReserveTcpPort();
|
||||||
|
int udpPort = ReserveUdpPort();
|
||||||
|
string secretDirectory = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"rendezvous-smoke-secret-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(secretDirectory);
|
||||||
|
File.SetUnixFileMode(
|
||||||
|
secretDirectory,
|
||||||
|
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||||
|
string secretPath = Path.Combine(secretDirectory, "signing-key");
|
||||||
|
await File.WriteAllBytesAsync(secretPath, RandomNumberGenerator.GetBytes(32));
|
||||||
|
File.SetUnixFileMode(secretPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||||
|
Process? server = null;
|
||||||
|
Process? smoke = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
string assembly = typeof(Program).Assembly.Location;
|
||||||
|
ProcessStartInfo serverInfo = new()
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
WorkingDirectory = root,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
ArgumentList =
|
||||||
|
{
|
||||||
|
assembly,
|
||||||
|
"--contentRoot", Path.Combine(root, "deploy", "compose"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:0:SecretReference", $"file:{secretPath}",
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:0:NotBefore", now.AddHours(-1).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:0:SignUntil", now.AddHours(1).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:0:VerifyUntil", now.AddHours(2).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:SecretReference", $"file:{secretPath}",
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:NotBefore", now.AddHours(-1).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:SignUntil", now.AddHours(1).ToString("O"),
|
||||||
|
"--Rendezvous:Provisioning:SigningKeys:1:VerifyUntil", now.AddHours(2).ToString("O"),
|
||||||
|
"--Rendezvous:Udp:Port", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
"--Rendezvous:Deployment:PublicUdpPort", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
serverInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Production";
|
||||||
|
serverInfo.Environment["ASPNETCORE_URLS"] = $"http://127.0.0.1:{httpPort}";
|
||||||
|
server = Process.Start(serverInfo)
|
||||||
|
?? throw new InvalidOperationException("The smoke server process did not start.");
|
||||||
|
Task<string> serverOutput = server.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> serverError = server.StandardError.ReadToEndAsync();
|
||||||
|
await WaitForReadyAsync(httpPort, server, TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
ProcessStartInfo smokeInfo = new()
|
||||||
|
{
|
||||||
|
FileName = Path.Combine(root, "scripts", "smoke-deployment.sh"),
|
||||||
|
WorkingDirectory = root,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
smokeInfo.Environment.Remove("RENDEZVOUS_PUBLISHER_CREDENTIAL");
|
||||||
|
smokeInfo.Environment["RENDEZVOUS_SMOKE_HTTP_URL"] = $"http://127.0.0.1:{httpPort}/";
|
||||||
|
smokeInfo.Environment["RENDEZVOUS_SMOKE_UDP_ENDPOINT"] = $"127.0.0.1:{udpPort}";
|
||||||
|
smokeInfo.Environment["RENDEZVOUS_SMOKE_LOCAL_KEY"] = secretPath;
|
||||||
|
smokeInfo.Environment["RENDEZVOUS_SMOKE_TIMEOUT_SECONDS"] = "15";
|
||||||
|
smokeInfo.Environment["RENDEZVOUS_SMOKE_CONFIGURATION"] = BuildConfiguration();
|
||||||
|
smoke = Process.Start(smokeInfo)
|
||||||
|
?? throw new InvalidOperationException("The deployment smoke process did not start.");
|
||||||
|
Task<string> smokeOutput = smoke.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> smokeError = smoke.StandardError.ReadToEndAsync();
|
||||||
|
using (CancellationTokenSource timeout = new(TimeSpan.FromSeconds(25)))
|
||||||
|
{
|
||||||
|
await smoke.WaitForExitAsync(timeout.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
string output = await smokeOutput;
|
||||||
|
string error = await smokeError;
|
||||||
|
Assert.True(
|
||||||
|
smoke.ExitCode == 0,
|
||||||
|
$"Smoke exited with {smoke.ExitCode}. stdout: {output} stderr: {error}");
|
||||||
|
Assert.Contains("deployment smoke passed", output, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("rv1.", output, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("rv1.", error, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
await SendSigtermAsync(server);
|
||||||
|
using CancellationTokenSource shutdownTimeout = new(TimeSpan.FromSeconds(6));
|
||||||
|
await server.WaitForExitAsync(shutdownTimeout.Token);
|
||||||
|
string finalServerOutput = await serverOutput;
|
||||||
|
string finalServerError = await serverError;
|
||||||
|
Assert.True(
|
||||||
|
server.ExitCode == 0,
|
||||||
|
$"Smoke server failed. stdout: {finalServerOutput} stderr: {finalServerError}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await StopProcessTreeAsync(smoke);
|
||||||
|
if (server is not null)
|
||||||
|
{
|
||||||
|
await StopProcessTreeAsync(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(secretPath);
|
||||||
|
Directory.Delete(secretDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("missing-deployment", "PublicHttpBaseUrl")]
|
||||||
|
[InlineData("wildcard-host", "AllowedHosts")]
|
||||||
|
[InlineData("reserved-endpoint", "public DNS name or address")]
|
||||||
|
[InlineData("missing-key", "process-test-key")]
|
||||||
|
public async Task UnsafeProductionConfigurationFailsBeforeBinding(
|
||||||
|
string scenario,
|
||||||
|
string expectedDiagnostic)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int httpPort = ReserveTcpPort();
|
||||||
|
int udpPort = ReserveUdpPort();
|
||||||
|
string secretPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"rendezvous-rejected-secret-{Guid.NewGuid():N}");
|
||||||
|
await File.WriteAllBytesAsync(secretPath, RandomNumberGenerator.GetBytes(32));
|
||||||
|
Process? process = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ProcessStartInfo startInfo = scenario == "missing-deployment"
|
||||||
|
? CreateBareProductionStartInfo(httpPort)
|
||||||
|
: CreateStartInfo(httpPort, udpPort, secretPath);
|
||||||
|
if (scenario == "wildcard-host")
|
||||||
|
{
|
||||||
|
startInfo.Environment["AllowedHosts"] = "*";
|
||||||
|
}
|
||||||
|
else if (scenario == "reserved-endpoint")
|
||||||
|
{
|
||||||
|
startInfo.Environment["Rendezvous__Deployment__AllowPrivatePublicEndpoints"] = "false";
|
||||||
|
startInfo.Environment["Rendezvous__Deployment__PublicHttpBaseUrl"] = "https://192.0.2.1/";
|
||||||
|
startInfo.Environment["Rendezvous__Deployment__PublicUdpHost"] = "203.0.113.1";
|
||||||
|
startInfo.Environment["AllowedHosts"] = "192.0.2.1";
|
||||||
|
}
|
||||||
|
else if (scenario == "missing-key")
|
||||||
|
{
|
||||||
|
File.Delete(secretPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
process = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException("The rejected production process did not start.");
|
||||||
|
Task<string> standardOutput = process.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> standardError = process.StandardError.ReadToEndAsync();
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(6));
|
||||||
|
await process.WaitForExitAsync(timeout.Token);
|
||||||
|
string diagnostic = $"{await standardOutput}\n{await standardError}";
|
||||||
|
|
||||||
|
Assert.NotEqual(0, process.ExitCode);
|
||||||
|
Assert.Contains(expectedDiagnostic, diagnostic, StringComparison.OrdinalIgnoreCase);
|
||||||
|
AssertTcpPortIsReleased(httpPort);
|
||||||
|
AssertUdpPortIsReleased(udpPort);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await StopProcessTreeAsync(process);
|
||||||
|
File.Delete(secretPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessStartInfo CreateStartInfo(int httpPort, int udpPort, string secretPath)
|
||||||
|
{
|
||||||
|
string assembly = typeof(Program).Assembly.Location;
|
||||||
|
ProcessStartInfo info = new()
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(assembly)!,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
info.ArgumentList.Add(assembly);
|
||||||
|
Dictionary<string, string> settings = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["ASPNETCORE_ENVIRONMENT"] = "Production",
|
||||||
|
["ASPNETCORE_URLS"] = $"http://127.0.0.1:{httpPort}",
|
||||||
|
["AllowedHosts"] = "127.0.0.1",
|
||||||
|
["Rendezvous__Deployment__PublicHttpBaseUrl"] = "https://127.0.0.1/",
|
||||||
|
["Rendezvous__Deployment__PublicUdpHost"] = "127.0.0.1",
|
||||||
|
["Rendezvous__Deployment__PublicUdpPort"] = udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
["Rendezvous__Deployment__DrainDeadlineSeconds"] = "3",
|
||||||
|
["Rendezvous__Deployment__MinimumDrainSeconds"] = "1",
|
||||||
|
["Rendezvous__Deployment__SingleActiveInstance"] = "true",
|
||||||
|
["Rendezvous__Deployment__AllowPrivatePublicEndpoints"] = "true",
|
||||||
|
["Rendezvous__Udp__ListenAddress"] = "127.0.0.1",
|
||||||
|
["Rendezvous__Udp__Port"] = udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
["Rendezvous__AbuseProtection__TrustedProxyAddresses__0"] = "127.0.0.1",
|
||||||
|
["Rendezvous__Provisioning__Issuer"] = "rendezvous-process-test",
|
||||||
|
["Rendezvous__Provisioning__Audience"] = "rendezvous-service",
|
||||||
|
["Rendezvous__Provisioning__ClockSkewSeconds"] = "30",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__KeyId"] = "process-test-key",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__SecretReference"] = $"file:{secretPath}",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__CredentialKinds__0"] = "DedicatedPublisher",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__GameId"] = "space-game",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__EnvironmentId"] = "process-test",
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__NotBefore"] = DateTimeOffset.UtcNow.AddHours(-1).ToString("O"),
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__SignUntil"] = DateTimeOffset.UtcNow.AddDays(1).ToString("O"),
|
||||||
|
["Rendezvous__Provisioning__SigningKeys__0__VerifyUntil"] = DateTimeOffset.UtcNow.AddDays(2).ToString("O"),
|
||||||
|
["Rendezvous__Provisioning__Games__0__GameId"] = "space-game",
|
||||||
|
["Rendezvous__Provisioning__Games__0__EnvironmentId"] = "process-test",
|
||||||
|
["Rendezvous__Provisioning__Games__0__Enabled"] = "true",
|
||||||
|
["Rendezvous__Provisioning__Games__0__ProtocolVersions__0"] = "1",
|
||||||
|
["Rendezvous__Provisioning__Games__0__Regions__0"] = "local",
|
||||||
|
["Rendezvous__Provisioning__Games__0__VisibilityModes__0"] = "Public",
|
||||||
|
["Rendezvous__Provisioning__Games__0__PublisherTrustModes__0"] = "ManagedDedicated",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MetadataMaxBytes"] = "512",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MetadataMaxKeys"] = "0",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MaxListingsPerPrincipal"] = "10",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MaxAnonymousListingsPerAddress"] = "0",
|
||||||
|
["Rendezvous__Provisioning__Games__0__MaxActiveJoinAttempts"] = "100",
|
||||||
|
["Rendezvous__Provisioning__Games__0__FallbackPolicy"] = "Disabled",
|
||||||
|
};
|
||||||
|
foreach ((string key, string value) in settings)
|
||||||
|
{
|
||||||
|
info.Environment[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessStartInfo CreateBareProductionStartInfo(int httpPort)
|
||||||
|
{
|
||||||
|
string assembly = typeof(Program).Assembly.Location;
|
||||||
|
ProcessStartInfo info = new()
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(assembly)!,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
info.ArgumentList.Add(assembly);
|
||||||
|
foreach (string key in info.Environment.Keys
|
||||||
|
.Where(static key => key.StartsWith("Rendezvous__", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray())
|
||||||
|
{
|
||||||
|
info.Environment.Remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Environment["ASPNETCORE_ENVIRONMENT"] = "Production";
|
||||||
|
info.Environment["ASPNETCORE_URLS"] = $"http://127.0.0.1:{httpPort}";
|
||||||
|
info.Environment["AllowedHosts"] = "*";
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task StopProcessTreeAsync(Process? process)
|
||||||
|
{
|
||||||
|
if (process is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
|
||||||
|
await process.WaitForExitAsync(timeout.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendSigtermAsync(Process process)
|
||||||
|
{
|
||||||
|
ProcessStartInfo signalInfo = new()
|
||||||
|
{
|
||||||
|
FileName = "/bin/kill",
|
||||||
|
UseShellExecute = false,
|
||||||
|
ArgumentList =
|
||||||
|
{
|
||||||
|
"-TERM",
|
||||||
|
process.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
using Process signal = Process.Start(signalInfo)
|
||||||
|
?? throw new InvalidOperationException("Could not send SIGTERM.");
|
||||||
|
await signal.WaitForExitAsync();
|
||||||
|
Assert.Equal(0, signal.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildConfiguration()
|
||||||
|
{
|
||||||
|
string path = typeof(ProductionProcessTests).Assembly.Location;
|
||||||
|
return path.Contains(
|
||||||
|
$"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}",
|
||||||
|
StringComparison.Ordinal)
|
||||||
|
? "Release"
|
||||||
|
: "Debug";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RepositoryRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
|
||||||
|
{
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory?.FullName
|
||||||
|
?? throw new InvalidOperationException("Could not locate the repository root.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForReadyAsync(int port, Process process, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using HttpClient client = new() { Timeout = TimeSpan.FromMilliseconds(500) };
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
while (elapsed.Elapsed < timeout)
|
||||||
|
{
|
||||||
|
if (process.HasExited)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The production server exited before readiness.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpResponseMessage response = await client.GetAsync(
|
||||||
|
$"http://127.0.0.1:{port}/health/ready");
|
||||||
|
if (response.StatusCode == HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("The production server did not become ready.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReserveTcpPort()
|
||||||
|
{
|
||||||
|
TcpListener listener = new(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReserveUdpPort()
|
||||||
|
{
|
||||||
|
using UdpClient client = new(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
return ((IPEndPoint)client.Client.LocalEndPoint!).Port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertUdpPortIsBound(int port)
|
||||||
|
{
|
||||||
|
using Socket socket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||||
|
Assert.Throws<SocketException>(() => socket.Bind(new IPEndPoint(IPAddress.Loopback, port)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertTcpPortIsReleased(int port)
|
||||||
|
{
|
||||||
|
TcpListener listener = new(IPAddress.Loopback, port);
|
||||||
|
listener.Start();
|
||||||
|
listener.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertUdpPortIsReleased(int port)
|
||||||
|
{
|
||||||
|
using Socket socket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||||
|
socket.Bind(new IPEndPoint(IPAddress.Loopback, port));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Documentation;
|
||||||
|
|
||||||
|
public sealed partial class DocumentationContractTests
|
||||||
|
{
|
||||||
|
private static readonly string[] IncidentScenarios =
|
||||||
|
[
|
||||||
|
"Abuse or authentication spike",
|
||||||
|
"Signing key or issuer compromise",
|
||||||
|
"Targeted listing or publisher revocation",
|
||||||
|
"Planned restart or crash recovery",
|
||||||
|
"Release rollback",
|
||||||
|
"Capacity saturation",
|
||||||
|
"Privacy or telemetry incident",
|
||||||
|
"Dependency or base-image upgrade",
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TestClientGuideDocumentsTheExecutableSuccessAndFailureContracts()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "test-client.md"));
|
||||||
|
|
||||||
|
Assert.Contains("space-game --environment smoke --region local --protocol 1", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("mint-local-publisher-credential.sh", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("join.connected", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("join.direct-traffic", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("browse.completed", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("--script --json", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("--run-seconds 60", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("for attempt in {1..45}", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("before the terminal-3", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("test \"$status\" -eq 11", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("no relay", guide, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("cannot guarantee", guide, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotMatch(ReusableCredential(), guide);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LocalCredentialHelperWhitelistsProvisionedGameScopesAndSmokeDelegatesToIt()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string helper = File.ReadAllText(Path.Combine(root, "scripts", "mint-local-publisher-credential.sh"));
|
||||||
|
string smoke = File.ReadAllText(Path.Combine(root, "scripts", "smoke-deployment.sh"));
|
||||||
|
|
||||||
|
Assert.Contains("if (( $# != 0 ));", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("space-game)", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("unscouted)", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("KEY_ID=\"local-smoke-1\"", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("KEY_ID=\"local-smoke-unscouted-1\"", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID must be space-game or unscouted", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"gameId\": game_id", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"environmentId\": \"smoke\"", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"regions\": [\"local\"]", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("now + 600", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("stat.S_ISLNK", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("parent.st_mode & 0o077", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("metadata.st_mode & 0o077", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("metadata.st_nlink != 1", helper, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("mint-local-publisher-credential.sh", smoke, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("hexkey:", smoke, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("openssl dgst", smoke, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnscoutedComposeTenantIsGameScopedAndMetadataBounded()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
using JsonDocument settings = JsonDocument.Parse(File.ReadAllText(
|
||||||
|
Path.Combine(root, "deploy", "compose", "appsettings.Production.json")));
|
||||||
|
JsonElement provisioning = settings.RootElement.GetProperty("Rendezvous").GetProperty("Provisioning");
|
||||||
|
JsonElement game = provisioning.GetProperty("Games").EnumerateArray().Single(
|
||||||
|
static item => item.GetProperty("GameId").GetString() == "unscouted");
|
||||||
|
JsonElement key = provisioning.GetProperty("SigningKeys").EnumerateArray().Single(
|
||||||
|
static item => item.GetProperty("KeyId").GetString() == "local-smoke-unscouted-1");
|
||||||
|
|
||||||
|
Assert.Equal("smoke", game.GetProperty("EnvironmentId").GetString());
|
||||||
|
Assert.Equal([1], game.GetProperty("ProtocolVersions").EnumerateArray().Select(static value => value.GetInt32()));
|
||||||
|
Assert.Equal(["mode", "mods", "world"], game.GetProperty("MetadataValueMaxBytes")
|
||||||
|
.EnumerateObject().Select(static property => property.Name).Order(StringComparer.Ordinal));
|
||||||
|
Assert.Equal(["mode", "mods", "world"], game.GetProperty("RequiredMetadataKeys")
|
||||||
|
.EnumerateArray().Select(static value => value.GetString()).Order(StringComparer.Ordinal));
|
||||||
|
Assert.Equal(3, game.GetProperty("MetadataMaxKeys").GetInt32());
|
||||||
|
Assert.Equal("DedicatedEndpointAllowed", game.GetProperty("FallbackPolicy").GetString());
|
||||||
|
Assert.Equal("unscouted", key.GetProperty("GameId").GetString());
|
||||||
|
Assert.Equal("smoke", key.GetProperty("EnvironmentId").GetString());
|
||||||
|
Assert.Equal(["DedicatedPublisher"], key.GetProperty("CredentialKinds")
|
||||||
|
.EnumerateArray().Select(static value => value.GetString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryIncidentRunbookHasDetectContainRecoverAndVerifyGates()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string runbooks = File.ReadAllText(Path.Combine(root, "docs", "operations", "incident-runbooks.md"));
|
||||||
|
|
||||||
|
for (int index = 0; index < IncidentScenarios.Length; index++)
|
||||||
|
{
|
||||||
|
string heading = $"## {IncidentScenarios[index]}";
|
||||||
|
int start = runbooks.IndexOf(heading, StringComparison.Ordinal);
|
||||||
|
Assert.True(start >= 0, $"Missing incident runbook heading: {heading}");
|
||||||
|
int end = index + 1 < IncidentScenarios.Length
|
||||||
|
? runbooks.IndexOf($"## {IncidentScenarios[index + 1]}", start, StringComparison.Ordinal)
|
||||||
|
: runbooks.Length;
|
||||||
|
Assert.True(end > start, $"Could not find the end of runbook: {heading}");
|
||||||
|
string scenario = runbooks[start..end];
|
||||||
|
|
||||||
|
Assert.Contains("### Detect", scenario, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("### Contain", scenario, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("### Recover", scenario, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("### Verify", scenario, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.DoesNotMatch(ReusableCredential(), runbooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DocumentedOperatorOperationsAndBodiesMatchTheReleasedOpenApi()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string runbooks = File.ReadAllText(Path.Combine(root, "docs", "operations", "incident-runbooks.md"));
|
||||||
|
using JsonDocument openApi = JsonDocument.Parse(File.ReadAllText(
|
||||||
|
Path.Combine(root, "docs", "api", "rendezvous-v1.json")));
|
||||||
|
JsonElement paths = openApi.RootElement.GetProperty("paths");
|
||||||
|
|
||||||
|
(string Method, string Path)[] documentedOperations = OperatorRoute().Matches(runbooks)
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(static match => (
|
||||||
|
match.Groups["method"].Value.ToLowerInvariant(),
|
||||||
|
match.Groups["path"].Value))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.NotEmpty(documentedOperations);
|
||||||
|
Assert.All(documentedOperations, operation =>
|
||||||
|
Assert.True(
|
||||||
|
paths.TryGetProperty(operation.Path, out JsonElement path)
|
||||||
|
&& path.TryGetProperty(operation.Method, out _),
|
||||||
|
$"OpenAPI does not contain {operation.Method.ToUpperInvariant()} {operation.Path}."));
|
||||||
|
|
||||||
|
Match[] actions = OperatorAction().Matches(runbooks).Cast<Match>().ToArray();
|
||||||
|
Assert.Equal(4, actions.Length);
|
||||||
|
foreach (Match action in actions)
|
||||||
|
{
|
||||||
|
string method = action.Groups["method"].Value.ToLowerInvariant();
|
||||||
|
string path = action.Groups["path"].Value;
|
||||||
|
using JsonDocument body = JsonDocument.Parse(action.Groups["body"].Value);
|
||||||
|
string reference = paths.GetProperty(path)
|
||||||
|
.GetProperty(method)
|
||||||
|
.GetProperty("requestBody")
|
||||||
|
.GetProperty("content")
|
||||||
|
.GetProperty("application/json")
|
||||||
|
.GetProperty("schema")
|
||||||
|
.GetProperty("$ref")
|
||||||
|
.GetString()!;
|
||||||
|
string schemaName = reference["#/components/schemas/".Length..];
|
||||||
|
string[] required = openApi.RootElement.GetProperty("components")
|
||||||
|
.GetProperty("schemas")
|
||||||
|
.GetProperty(schemaName)
|
||||||
|
.GetProperty("required")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(static property => property.GetString()!)
|
||||||
|
.Order(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
string[] documented = body.RootElement.EnumerateObject()
|
||||||
|
.Select(static property => property.Name)
|
||||||
|
.Order(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(required, documented);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SdkGuideMatchesTheReleasedPackageAndTransportMatrix()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "sdk-seams.md"));
|
||||||
|
using JsonDocument compatibility = JsonDocument.Parse(File.ReadAllText(
|
||||||
|
Path.Combine(root, "docs", "releases", "compatibility.json")));
|
||||||
|
JsonElement matrix = compatibility.RootElement;
|
||||||
|
JsonElement packages = matrix.GetProperty("packages");
|
||||||
|
string clientVersion = packages.GetProperty("FinalFactory.Rendezvous.Client").GetString()!;
|
||||||
|
string contractsVersion = packages.GetProperty("FinalFactory.Rendezvous.Contracts").GetString()!;
|
||||||
|
string liteNetLibVersion = matrix.GetProperty("transport").GetProperty("version").GetString()!;
|
||||||
|
string clientFramework = XDocument.Load(Path.Combine(
|
||||||
|
root,
|
||||||
|
"src",
|
||||||
|
"FinalFactory.Rendezvous.Client",
|
||||||
|
"FinalFactory.Rendezvous.Client.csproj"))
|
||||||
|
.Descendants("TargetFramework")
|
||||||
|
.Single()
|
||||||
|
.Value;
|
||||||
|
int httpVersion = matrix.GetProperty("contracts").GetProperty("http")[0].GetInt32();
|
||||||
|
int udpVersion = matrix.GetProperty("contracts").GetProperty("udp")[0].GetInt32();
|
||||||
|
int ticketVersion = matrix.GetProperty("contracts").GetProperty("connectionTicket")[0].GetInt32();
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
$"FinalFactory.Rendezvous.Client\" Version=\"{clientVersion}\"",
|
||||||
|
guide,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
$"FinalFactory.Rendezvous.Contracts\" Version=\"{contractsVersion}\"",
|
||||||
|
guide,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains($"targets `{clientFramework}`", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains($"LiteNetLib `{liteNetLibVersion}`", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"https://git.finalfactory.de/api/packages/HeiKyu/nuget/index.json",
|
||||||
|
guide,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Equal(httpVersion, udpVersion);
|
||||||
|
Assert.Equal(httpVersion, ticketVersion);
|
||||||
|
Assert.Contains($"contract version `{httpVersion}`", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("gameplayNetwork.ChannelsCount = 3", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("defaults to one QoS channel", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Rendezvous does not choose, remap, or reserve", guide, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpaceGamePilotEvidenceSeparatesProvenBehaviorFromOpenGates()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "spacegame-pilot.md"));
|
||||||
|
string deploymentGuide = File.ReadAllText(Path.Combine(root, "docs", "deployment", "linux.md"));
|
||||||
|
using JsonDocument evidence = JsonDocument.Parse(File.ReadAllText(
|
||||||
|
Path.Combine(root, "docs", "evidence", "consumers", "spacegame.json")));
|
||||||
|
JsonElement record = evidence.RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("checkpoint-pass-with-external-gates", record.GetProperty("result").GetString());
|
||||||
|
Assert.Equal("none", record.GetProperty("localRun").GetProperty("rendezvousGameplayPayloadPath").GetString());
|
||||||
|
Assert.Equal("caller-owned-litenetlib", record.GetProperty("localRun").GetProperty("gameplayTransport").GetString());
|
||||||
|
Assert.True(record.GetProperty("localRun").GetProperty("directGameplay").GetBoolean());
|
||||||
|
Assert.True(record.GetProperty("localRun").GetProperty("reconnected").GetBoolean());
|
||||||
|
Assert.True(record.GetProperty("linuxRun").GetProperty("freshExport").GetBoolean());
|
||||||
|
Assert.False(record.GetProperty("linuxRun").GetProperty("sourceDirty").GetBoolean());
|
||||||
|
Assert.Equal(31, record.GetProperty("verification").GetProperty("debugTests").GetProperty("passed").GetInt32());
|
||||||
|
Assert.Equal(31, record.GetProperty("verification").GetProperty("releaseTests").GetProperty("passed").GetInt32());
|
||||||
|
Assert.Contains("public-package-restore", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||||
|
Assert.Contains("representative-external-nat", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||||
|
Assert.DoesNotContain("actual-godot-process-integration", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||||
|
Assert.DoesNotContain("dedicated-fallback-connection", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||||
|
Assert.Contains("Do not mark #21 passed", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Rendezvous reserves no gameplay", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("private-network service name", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("local/private-bridge smoke", deploymentGuide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("explicit `rendezvous` host name", deploymentGuide, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
using JsonDocument composeSettings = JsonDocument.Parse(File.ReadAllText(
|
||||||
|
Path.Combine(root, "deploy", "compose", "appsettings.Production.json")));
|
||||||
|
JsonElement compose = composeSettings.RootElement;
|
||||||
|
Assert.Contains("rendezvous", compose.GetProperty("AllowedHosts").GetString()!.Split(';'));
|
||||||
|
JsonElement game = compose.GetProperty("Rendezvous").GetProperty("Provisioning").GetProperty("Games")[0];
|
||||||
|
Assert.Contains(2, game.GetProperty("ProtocolVersions").EnumerateArray().Select(static version => version.GetInt32()));
|
||||||
|
Assert.Equal("DedicatedEndpointAllowed", game.GetProperty("FallbackPolicy").GetString());
|
||||||
|
Assert.DoesNotMatch(ReusableCredential(), guide);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnscoutedPilotEvidenceProvesAnIndependentGameBoundaryAndKeepsExternalGatesOpen()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "unscouted-pilot.md"));
|
||||||
|
using JsonDocument evidence = JsonDocument.Parse(File.ReadAllText(
|
||||||
|
Path.Combine(root, "docs", "evidence", "consumers", "unscouted.json")));
|
||||||
|
JsonElement record = evidence.RootElement;
|
||||||
|
JsonElement run = record.GetProperty("godotRun");
|
||||||
|
JsonElement negative = record.GetProperty("negativePaths");
|
||||||
|
|
||||||
|
Assert.Equal("checkpoint-pass-with-external-gates", record.GetProperty("result").GetString());
|
||||||
|
Assert.Equal("unscouted", record.GetProperty("configuration").GetProperty("gameId").GetString());
|
||||||
|
Assert.Equal(["mode", "world", "mods"], record.GetProperty("configuration").GetProperty("metadataKeys")
|
||||||
|
.EnumerateArray().Select(static value => value.GetString()));
|
||||||
|
Assert.Equal("none", run.GetProperty("rendezvousGameplayPayloadPath").GetString());
|
||||||
|
Assert.Equal("unscouted-litenetlib", run.GetProperty("gameplayTransport").GetString());
|
||||||
|
Assert.True(run.GetProperty("directGameplay").GetBoolean());
|
||||||
|
Assert.True(run.GetProperty("fallbackGameplay").GetBoolean());
|
||||||
|
Assert.Equal(2, run.GetProperty("authenticatedSessions").GetInt32());
|
||||||
|
Assert.Equal("proven-exact-NotFound", negative.GetProperty("wrongGame").GetString());
|
||||||
|
Assert.Equal("proven-exact-NotFound", negative.GetProperty("wrongEnvironment").GetString());
|
||||||
|
Assert.Equal(3310, record.GetProperty("verification").GetProperty("consumerDebugTests").GetProperty("passed").GetInt32());
|
||||||
|
Assert.Equal(360, record.GetProperty("verification").GetProperty("consumerGdUnitTests").GetProperty("passed").GetInt32());
|
||||||
|
Assert.Contains("public-package-restore", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||||
|
Assert.Contains("representative-external-nat", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
|
||||||
|
Assert.Contains("Do not mark #22 passed", guide, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("not an Unscouted branch", guide, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotMatch(ReusableCredential(), guide);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"rv1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex ReusableCredential();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(?<method>GET|POST) `?(?<path>/v1/operator/[a-z/-]+)`?", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex OperatorRoute();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\| `(?<method>POST) (?<path>/v1/operator/[a-z/-]+)` \| `(?<body>\{[^`]+\})` \|", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex OperatorAction();
|
||||||
|
|
||||||
|
private static string FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(current.FullName, "Rendezvous.slnx")))
|
||||||
|
{
|
||||||
|
return current.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Could not locate the repository root.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Observability;
|
||||||
|
|
||||||
|
[CollectionDefinition(RendezvousTelemetryIsolation.Name, DisableParallelization = true)]
|
||||||
|
public sealed class RendezvousTelemetryIsolation
|
||||||
|
{
|
||||||
|
public const string Name = "Rendezvous telemetry";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection(RendezvousTelemetryIsolation.Name)]
|
||||||
|
public sealed class ObservabilityTests
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedTagKeys =
|
||||||
|
[
|
||||||
|
"operation",
|
||||||
|
"status_code",
|
||||||
|
"result",
|
||||||
|
"transport",
|
||||||
|
"partition",
|
||||||
|
"action",
|
||||||
|
"outcome",
|
||||||
|
"elapsed_bucket",
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MetricsAndTracesUseBoundedDimensionsWithoutSensitiveValues()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
using RendezvousTelemetry telemetry = new(fixture.Store);
|
||||||
|
List<Measurement> measurements = [];
|
||||||
|
using MeterListener meterListener = new();
|
||||||
|
meterListener.InstrumentPublished = (instrument, listener) =>
|
||||||
|
{
|
||||||
|
if (instrument.Meter.Name == RendezvousTelemetry.MeterName)
|
||||||
|
{
|
||||||
|
listener.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
meterListener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||||
|
measurements.Add(new(instrument.Name, value, Tags(tags))));
|
||||||
|
meterListener.SetMeasurementEventCallback<int>((instrument, value, tags, _) =>
|
||||||
|
measurements.Add(new(instrument.Name, value, Tags(tags))));
|
||||||
|
meterListener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
|
||||||
|
measurements.Add(new(instrument.Name, value, Tags(tags))));
|
||||||
|
meterListener.Start();
|
||||||
|
|
||||||
|
Activity? observed = null;
|
||||||
|
List<string> activityData = [];
|
||||||
|
using ActivityListener activityListener = new()
|
||||||
|
{
|
||||||
|
ShouldListenTo = source => source.Name == RendezvousTelemetry.ActivitySourceName,
|
||||||
|
Sample = static (ref ActivityCreationOptions<ActivityContext> _) =>
|
||||||
|
ActivitySamplingResult.AllData,
|
||||||
|
ActivityStopped = activity =>
|
||||||
|
{
|
||||||
|
observed = activity;
|
||||||
|
activityData.Add(activity.DisplayName);
|
||||||
|
activityData.AddRange(activity.TagObjects.Select(static tag => $"{tag.Key}={tag.Value}"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ActivitySource.AddActivityListener(activityListener);
|
||||||
|
|
||||||
|
const string secret = "secret-player-token-canary";
|
||||||
|
using (telemetry.StartActivity("HTTP GetOperatorStatus", ActivityKind.Server))
|
||||||
|
{
|
||||||
|
telemetry.RecordHttp("GetOperatorStatus", 200, 3.5);
|
||||||
|
telemetry.RecordUdp("frozen", "Introduced", 1.25);
|
||||||
|
telemetry.RecordLimiterDrop("udp", "rate-or-concurrency");
|
||||||
|
telemetry.RecordAudit("revoke-listing", "succeeded");
|
||||||
|
telemetry.RecordConnectionOutcome("Connected", "UnderOneSecond");
|
||||||
|
telemetry.RecordOperatorAuthentication("accepted");
|
||||||
|
telemetry.RecordPairingLatency(12.5);
|
||||||
|
}
|
||||||
|
NatMediationProcessor processor = new(null!, null!, null!, telemetry: telemetry);
|
||||||
|
Assert.Equal(
|
||||||
|
NatMediationResult.Dropped,
|
||||||
|
processor.ProcessRequest(
|
||||||
|
new IPEndPoint(IPAddress.Parse("10.0.0.8"), 9000),
|
||||||
|
new IPEndPoint(IPAddress.Parse("203.0.113.8"), 50000),
|
||||||
|
secret,
|
||||||
|
NoopIntroductionSink.Instance));
|
||||||
|
|
||||||
|
meterListener.RecordObservableInstruments();
|
||||||
|
Assert.NotNull(observed);
|
||||||
|
string flattened = string.Join('|', measurements.Select(static item => item.ToString()));
|
||||||
|
Assert.DoesNotContain(secret, flattened, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(secret, string.Join('|', activityData), StringComparison.Ordinal);
|
||||||
|
Assert.Contains(measurements, static item => item.Name == "rendezvous.http.requests");
|
||||||
|
Assert.Contains(measurements, static item => item.Name == "rendezvous.udp.results");
|
||||||
|
Assert.Contains(measurements, static item => item.Name == "rendezvous.limiter.drops");
|
||||||
|
Assert.Contains(measurements, static item => item.Name == "rendezvous.queue.depth");
|
||||||
|
Assert.Contains(measurements, static item => item.Name == "rendezvous.store.active_leases");
|
||||||
|
Assert.Contains(measurements, static item => item.Name == "rendezvous.store.expiry_churn");
|
||||||
|
Assert.Contains(measurements, static item => item.Name == "rendezvous.pairing.latency");
|
||||||
|
Assert.All(measurements.SelectMany(static item => item.Tags), static tag =>
|
||||||
|
Assert.Contains(tag.Key, AllowedTagKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MetricScrapeExpiresIdleStateAndReportsExpiryChurn()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
Assert.True(fixture.Store.CreateListing(fixture.ListingCommand()).Succeeded);
|
||||||
|
using RendezvousTelemetry telemetry = new(fixture.Store);
|
||||||
|
List<Measurement> measurements = [];
|
||||||
|
using MeterListener listener = new();
|
||||||
|
listener.InstrumentPublished = (instrument, meterListener) =>
|
||||||
|
{
|
||||||
|
if (instrument.Meter.Name == RendezvousTelemetry.MeterName)
|
||||||
|
{
|
||||||
|
meterListener.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||||
|
measurements.Add(new(instrument.Name, value, Tags(tags))));
|
||||||
|
listener.SetMeasurementEventCallback<int>((instrument, value, tags, _) =>
|
||||||
|
measurements.Add(new(instrument.Name, value, Tags(tags))));
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
listener.RecordObservableInstruments();
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
Assert.Single(measurements, static item => item.Name == "rendezvous.store.active_leases").Value);
|
||||||
|
|
||||||
|
fixture.Clock.Advance(TimeSpan.FromSeconds(61));
|
||||||
|
measurements.Clear();
|
||||||
|
listener.RecordObservableInstruments();
|
||||||
|
Assert.Equal(
|
||||||
|
0,
|
||||||
|
Assert.Single(measurements, static item => item.Name == "rendezvous.store.active_leases").Value);
|
||||||
|
Assert.True(Assert.Single(
|
||||||
|
measurements,
|
||||||
|
static item => item.Name == "rendezvous.store.expiry_churn").Value >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuditTrailFingerprintsIdentifiersEnforcesRetentionAndBoundsCapacity()
|
||||||
|
{
|
||||||
|
EphemeralStateFixture fixture = new();
|
||||||
|
using RendezvousTelemetry telemetry = new(fixture.Store);
|
||||||
|
CapturingLogger<AuditTrail> logger = new();
|
||||||
|
ManualTimeProvider time = new(new DateTimeOffset(2026, 7, 16, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
AuditTrail audit = new(
|
||||||
|
Options.Create(new AuditOptions { MaxEntries = 100, RetentionDays = 1 }),
|
||||||
|
logger,
|
||||||
|
telemetry,
|
||||||
|
time);
|
||||||
|
const string actor = "operator-secret-subject";
|
||||||
|
const string target = "player-secret-subject";
|
||||||
|
|
||||||
|
for (int index = 0; index < 101; index++)
|
||||||
|
{
|
||||||
|
audit.Record(actor, "revoke-principal", "succeeded", "principal", target, "safe-correlation");
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<AuditEntry> bounded = audit.GetEntriesForTests();
|
||||||
|
Assert.Equal(100, bounded.Count);
|
||||||
|
Assert.All(bounded, entry =>
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(actor, entry.ToString(), StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(target, entry.ToString(), StringComparison.Ordinal);
|
||||||
|
Assert.NotEqual(actor, entry.ActorFingerprint);
|
||||||
|
Assert.NotEqual(target, entry.TargetFingerprint);
|
||||||
|
});
|
||||||
|
Assert.DoesNotContain(actor, string.Join('|', logger.Messages), StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(target, string.Join('|', logger.Messages), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
time.Advance(TimeSpan.FromDays(2));
|
||||||
|
Assert.Empty(audit.GetEntriesForTests());
|
||||||
|
Assert.Empty(audit.GetAggregateCounts());
|
||||||
|
audit.Record(actor, "inspect-status", "succeeded", "service", "rendezvous", "safe-correlation");
|
||||||
|
Assert.Single(audit.GetEntriesForTests());
|
||||||
|
Assert.Equal(1, audit.GetAggregateCounts()["inspect-status:succeeded"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyValuePair<string, object?>[] Tags(
|
||||||
|
ReadOnlySpan<KeyValuePair<string, object?>> tags) => tags.ToArray();
|
||||||
|
|
||||||
|
private sealed record Measurement(
|
||||||
|
string Name,
|
||||||
|
double Value,
|
||||||
|
KeyValuePair<string, object?>[] Tags);
|
||||||
|
|
||||||
|
private sealed class ManualTimeProvider(DateTimeOffset now) : TimeProvider
|
||||||
|
{
|
||||||
|
private DateTimeOffset _now = now;
|
||||||
|
public override DateTimeOffset GetUtcNow() => _now;
|
||||||
|
public void Advance(TimeSpan duration) => _now += duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopIntroductionSink : INatIntroductionSink
|
||||||
|
{
|
||||||
|
public static NoopIntroductionSink Instance { get; } = new();
|
||||||
|
public void Introduce(NatIntroductionPlan plan)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||||
|
{
|
||||||
|
public List<string> Messages { get; } = [];
|
||||||
|
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter) => Messages.Add(formatter(state, exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CapturingLoggerProvider : ILoggerProvider
|
||||||
|
{
|
||||||
|
public ConcurrentQueue<string> Messages { get; } = new();
|
||||||
|
|
||||||
|
public ILogger CreateLogger(string categoryName) => new Sink(Messages);
|
||||||
|
|
||||||
|
public void Dispose() => GC.SuppressFinalize(this);
|
||||||
|
|
||||||
|
private sealed class Sink(ConcurrentQueue<string> messages) : ILogger
|
||||||
|
{
|
||||||
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => Scope.Instance;
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter) => messages.Enqueue(formatter(state, exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Scope : IDisposable
|
||||||
|
{
|
||||||
|
public static Scope Instance { get; } = new();
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Abuse;
|
||||||
|
using FinalFactory.Rendezvous.Server.Http;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Server.Operations;
|
||||||
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Server.Sessions;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Observability;
|
||||||
|
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||||
|
using FinalFactory.Rendezvous.Tests.State;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Operations;
|
||||||
|
|
||||||
|
[Collection(RendezvousTelemetryIsolation.Name)]
|
||||||
|
public sealed class OperatorEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task OperatorSurfaceSeparatesAuthenticationConfirmsActionsAndRedactsInspection()
|
||||||
|
{
|
||||||
|
await using OperatorTestHost host = await OperatorTestHost.StartAsync();
|
||||||
|
List<string> telemetryData = [];
|
||||||
|
using MeterListener meterListener = new();
|
||||||
|
meterListener.InstrumentPublished = (instrument, listener) =>
|
||||||
|
{
|
||||||
|
if (instrument.Meter.Name == RendezvousTelemetry.MeterName)
|
||||||
|
{
|
||||||
|
listener.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
meterListener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||||
|
CaptureMeasurement(telemetryData, instrument, value, tags));
|
||||||
|
meterListener.SetMeasurementEventCallback<int>((instrument, value, tags, _) =>
|
||||||
|
CaptureMeasurement(telemetryData, instrument, value, tags));
|
||||||
|
meterListener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
|
||||||
|
CaptureMeasurement(telemetryData, instrument, value, tags));
|
||||||
|
meterListener.Start();
|
||||||
|
using ActivityListener activityListener = new()
|
||||||
|
{
|
||||||
|
ShouldListenTo = static source => source.Name == RendezvousTelemetry.ActivitySourceName,
|
||||||
|
Sample = static (ref ActivityCreationOptions<ActivityContext> _) =>
|
||||||
|
ActivitySamplingResult.AllData,
|
||||||
|
ActivityStopped = activity =>
|
||||||
|
{
|
||||||
|
telemetryData.Add(activity.DisplayName);
|
||||||
|
telemetryData.AddRange(activity.TagObjects.Select(static tag => $"{tag.Key}={tag.Value}"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ActivitySource.AddActivityListener(activityListener);
|
||||||
|
|
||||||
|
using HttpResponseMessage liveBeforeDependencies = await host.Client.GetAsync("/health/live");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, liveBeforeDependencies.StatusCode);
|
||||||
|
using HttpResponseMessage readyBeforeUdp = await host.Client.GetAsync("/health/ready");
|
||||||
|
Assert.Equal(HttpStatusCode.ServiceUnavailable, readyBeforeUdp.StatusCode);
|
||||||
|
|
||||||
|
using HttpResponseMessage unauthenticated = await host.Client.GetAsync("/v1/operator/status");
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
|
||||||
|
AuthenticationHeaderValue challenge = Assert.Single(
|
||||||
|
unauthenticated.Headers.WwwAuthenticate);
|
||||||
|
Assert.Equal("Bearer", challenge.Scheme);
|
||||||
|
Assert.Equal("realm=\"operator\"", challenge.Parameter);
|
||||||
|
|
||||||
|
using HttpResponseMessage publisher = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/v1/operator/status",
|
||||||
|
host.PublisherCredential);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, publisher.StatusCode);
|
||||||
|
|
||||||
|
using HttpResponseMessage status = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/v1/operator/status",
|
||||||
|
host.ReadOnlyOperatorCredential);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, status.StatusCode);
|
||||||
|
string statusJson = await status.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("not-ready", statusJson, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(host.OwnerCanary, statusJson, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("203.0.113.25", statusJson, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("metadata", statusJson, StringComparison.OrdinalIgnoreCase);
|
||||||
|
OperatorStatusResponse? operatorStatus = JsonSerializer.Deserialize<OperatorStatusResponse>(
|
||||||
|
statusJson,
|
||||||
|
ContractJson.Options);
|
||||||
|
Assert.Contains(operatorStatus!.Tenants, static tenant =>
|
||||||
|
tenant.GameId == "space-game"
|
||||||
|
&& tenant.EnvironmentId == "production"
|
||||||
|
&& tenant.Status == "enabled");
|
||||||
|
Assert.Equal("1.0.0", operatorStatus.Compatibility.ServerVersion);
|
||||||
|
Assert.Equal("1.0.0", operatorStatus.Compatibility.MinimumClientVersion);
|
||||||
|
Assert.Equal(1, operatorStatus.Compatibility.MaximumClientMajorVersion);
|
||||||
|
Assert.Equal([1], operatorStatus.Compatibility.HttpContractVersions);
|
||||||
|
Assert.Equal([1], operatorStatus.Compatibility.UdpContractVersions);
|
||||||
|
Assert.Equal([1], operatorStatus.Compatibility.ConnectionTicketFormatVersions);
|
||||||
|
Assert.Equal(2, operatorStatus.Compatibility.LiteNetLibMajorVersion);
|
||||||
|
Assert.Equal(
|
||||||
|
"exact-per-tenant",
|
||||||
|
operatorStatus.Compatibility.GameplayProtocolCompatibility);
|
||||||
|
Assert.Contains(operatorStatus.SigningKeys, static key =>
|
||||||
|
key.KeyId == OperatorTestHost.OperatorKeyId
|
||||||
|
&& key.Status == "signing"
|
||||||
|
&& key.CredentialKinds.SequenceEqual(["Operator"]));
|
||||||
|
|
||||||
|
await host.StartUdpAsync();
|
||||||
|
using HttpResponseMessage readyAfterUdp = await host.Client.GetAsync("/health/ready");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, readyAfterUdp.StatusCode);
|
||||||
|
|
||||||
|
using HttpResponseMessage exception = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/test/exception",
|
||||||
|
host.FullOperatorCredential);
|
||||||
|
Assert.Equal(HttpStatusCode.InternalServerError, exception.StatusCode);
|
||||||
|
using HttpResponseMessage saturatedPublic = await host.Client.GetAsync("/test/public");
|
||||||
|
Assert.Equal(HttpStatusCode.TooManyRequests, saturatedPublic.StatusCode);
|
||||||
|
|
||||||
|
using HttpResponseMessage forbidden = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/drain",
|
||||||
|
host.ReadOnlyOperatorCredential,
|
||||||
|
new BeginDrainRequest { Confirmation = "DRAIN" });
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
||||||
|
|
||||||
|
SessionListingId listingId = host.CreateListing(host.OwnerCanary);
|
||||||
|
using HttpResponseMessage unconfirmedListing = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/listings/revoke",
|
||||||
|
host.FullOperatorCredential,
|
||||||
|
new RevokeListingRequest
|
||||||
|
{
|
||||||
|
ListingId = listingId.ToString(),
|
||||||
|
ConfirmListingId = Guid.NewGuid().ToString("D"),
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, unconfirmedListing.StatusCode);
|
||||||
|
Assert.True(host.Store.GetListing(listingId, false).Succeeded);
|
||||||
|
|
||||||
|
using HttpResponseMessage revokedListing = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/listings/revoke",
|
||||||
|
host.FullOperatorCredential,
|
||||||
|
new RevokeListingRequest
|
||||||
|
{
|
||||||
|
ListingId = listingId.ToString(),
|
||||||
|
ConfirmListingId = listingId.ToString(),
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, revokedListing.StatusCode);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(listingId, false).Code);
|
||||||
|
|
||||||
|
const string principalCanary = "publisher-player-canary";
|
||||||
|
SessionListingId principalListing = host.CreateListing(principalCanary);
|
||||||
|
using HttpResponseMessage unconfirmedPrincipal = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/principals/revoke",
|
||||||
|
host.FullOperatorCredential,
|
||||||
|
new RevokePrincipalRequest
|
||||||
|
{
|
||||||
|
Subject = principalCanary,
|
||||||
|
ConfirmSubject = "different-subject",
|
||||||
|
LifetimeSeconds = 60,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, unconfirmedPrincipal.StatusCode);
|
||||||
|
Assert.True(host.Store.GetListing(principalListing, false).Succeeded);
|
||||||
|
|
||||||
|
using HttpResponseMessage revokedPrincipal = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/principals/revoke",
|
||||||
|
host.FullOperatorCredential,
|
||||||
|
new RevokePrincipalRequest
|
||||||
|
{
|
||||||
|
Subject = principalCanary,
|
||||||
|
ConfirmSubject = principalCanary,
|
||||||
|
LifetimeSeconds = 60,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, revokedPrincipal.StatusCode);
|
||||||
|
OperatorActionResponse? principalResult = await revokedPrincipal.Content
|
||||||
|
.ReadFromJsonAsync<OperatorActionResponse>(ContractJson.Options);
|
||||||
|
Assert.Equal(1, principalResult!.AffectedResources);
|
||||||
|
Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(principalListing, false).Code);
|
||||||
|
StoreResult<StoredListing> blockedPublisher = host.CreateListingResult(
|
||||||
|
principalCanary,
|
||||||
|
out _);
|
||||||
|
Assert.Equal(StoreResultCode.Revoked, blockedPublisher.Code);
|
||||||
|
|
||||||
|
using HttpResponseMessage drain = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/drain",
|
||||||
|
host.FullOperatorCredential,
|
||||||
|
new BeginDrainRequest { Confirmation = "DRAIN" });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, drain.StatusCode);
|
||||||
|
Assert.True(host.Store.IsDraining);
|
||||||
|
using HttpResponseMessage liveDuringDrain = await host.Client.GetAsync("/health/live");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, liveDuringDrain.StatusCode);
|
||||||
|
using HttpResponseMessage readyDuringDrain = await host.Client.GetAsync("/health/ready");
|
||||||
|
Assert.Equal(HttpStatusCode.ServiceUnavailable, readyDuringDrain.StatusCode);
|
||||||
|
|
||||||
|
using HttpResponseMessage firstPublisherKeyRevocation = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/keys/revoke",
|
||||||
|
host.FullOperatorCredential,
|
||||||
|
new RevokeSigningKeyRequest { KeyId = "key-1", ConfirmKeyId = "key-1" });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, firstPublisherKeyRevocation.StatusCode);
|
||||||
|
using HttpResponseMessage repeatedPublisherKeyRevocation = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/keys/revoke",
|
||||||
|
host.FullOperatorCredential,
|
||||||
|
new RevokeSigningKeyRequest { KeyId = "key-1", ConfirmKeyId = "key-1" });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, repeatedPublisherKeyRevocation.StatusCode);
|
||||||
|
|
||||||
|
using HttpResponseMessage unconfirmedKey = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/keys/revoke",
|
||||||
|
host.FullOperatorCredential,
|
||||||
|
new RevokeSigningKeyRequest
|
||||||
|
{
|
||||||
|
KeyId = OperatorTestHost.OperatorKeyId,
|
||||||
|
ConfirmKeyId = "different-key",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, unconfirmedKey.StatusCode);
|
||||||
|
|
||||||
|
using HttpResponseMessage revokedKey = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/v1/operator/keys/revoke",
|
||||||
|
host.FullOperatorCredential,
|
||||||
|
new RevokeSigningKeyRequest
|
||||||
|
{
|
||||||
|
KeyId = OperatorTestHost.OperatorKeyId,
|
||||||
|
ConfirmKeyId = OperatorTestHost.OperatorKeyId,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, revokedKey.StatusCode);
|
||||||
|
using HttpResponseMessage afterKeyRevocation = await SendAsync(
|
||||||
|
host,
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/v1/operator/status",
|
||||||
|
host.FullOperatorCredential);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, afterKeyRevocation.StatusCode);
|
||||||
|
|
||||||
|
string auditText = string.Join('|', host.Audit.GetEntriesForTests());
|
||||||
|
string logText = string.Join('|', host.AuditLogger.Messages.Concat(host.AllLogs.Messages));
|
||||||
|
Assert.DoesNotContain(host.OwnerCanary, auditText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(principalCanary, auditText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(listingId.ToString(), auditText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(host.OwnerCanary, logText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(principalCanary, logText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("exception-secret-canary", logText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(host.FullOperatorCredential, logText, StringComparison.Ordinal);
|
||||||
|
meterListener.RecordObservableInstruments();
|
||||||
|
string telemetryText = string.Join('|', telemetryData);
|
||||||
|
Assert.DoesNotContain(host.OwnerCanary, telemetryText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(principalCanary, telemetryText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("exception-secret-canary", telemetryText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(host.FullOperatorCredential, telemetryText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(listingId.ToString(), telemetryText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("203.0.113.25", telemetryText, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(host.Audit.GetEntriesForTests(), static entry =>
|
||||||
|
entry.Action == "begin-drain" && entry.Result == "succeeded");
|
||||||
|
Assert.Contains(host.Audit.GetEntriesForTests(), static entry =>
|
||||||
|
entry.Action == "revoke-listing" && entry.Result == "rejected");
|
||||||
|
Assert.Contains(host.Audit.GetEntriesForTests(), static entry =>
|
||||||
|
entry.Action == "begin-drain" && entry.Result == "forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HttpResponseMessage> SendAsync(
|
||||||
|
OperatorTestHost host,
|
||||||
|
HttpMethod method,
|
||||||
|
string path,
|
||||||
|
string bearer,
|
||||||
|
object? body = null)
|
||||||
|
{
|
||||||
|
using HttpRequestMessage request = new(method, path);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
if (body is not null)
|
||||||
|
{
|
||||||
|
request.Content = JsonContent.Create(body, options: ContractJson.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await host.Client.SendAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CaptureMeasurement<T>(
|
||||||
|
List<string> destination,
|
||||||
|
Instrument instrument,
|
||||||
|
T value,
|
||||||
|
ReadOnlySpan<KeyValuePair<string, object?>> tags)
|
||||||
|
where T : struct
|
||||||
|
{
|
||||||
|
destination.Add($"{instrument.Name}={value}");
|
||||||
|
destination.AddRange(tags.ToArray().Select(static tag => $"{tag.Key}={tag.Value}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OperatorTestHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _application;
|
||||||
|
private int _listingSequence;
|
||||||
|
private bool _udpStarted;
|
||||||
|
|
||||||
|
private OperatorTestHost(
|
||||||
|
WebApplication application,
|
||||||
|
HttpClient client,
|
||||||
|
ManualRendezvousClock clock,
|
||||||
|
InMemoryEphemeralRendezvousStore store,
|
||||||
|
AuditTrail audit,
|
||||||
|
CapturingLogger<AuditTrail> auditLogger,
|
||||||
|
CapturingLoggerProvider allLogs,
|
||||||
|
string publisherCredential,
|
||||||
|
string readOnlyOperatorCredential,
|
||||||
|
string fullOperatorCredential)
|
||||||
|
{
|
||||||
|
_application = application;
|
||||||
|
Client = client;
|
||||||
|
Clock = clock;
|
||||||
|
Store = store;
|
||||||
|
Audit = audit;
|
||||||
|
AuditLogger = auditLogger;
|
||||||
|
AllLogs = allLogs;
|
||||||
|
PublisherCredential = publisherCredential;
|
||||||
|
ReadOnlyOperatorCredential = readOnlyOperatorCredential;
|
||||||
|
FullOperatorCredential = fullOperatorCredential;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const string OperatorKeyId = "operator-key";
|
||||||
|
internal string OwnerCanary { get; } = "publisher-owner-canary";
|
||||||
|
internal HttpClient Client { get; }
|
||||||
|
internal ManualRendezvousClock Clock { get; }
|
||||||
|
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||||
|
internal AuditTrail Audit { get; }
|
||||||
|
internal CapturingLogger<AuditTrail> AuditLogger { get; }
|
||||||
|
internal CapturingLoggerProvider AllLogs { get; }
|
||||||
|
internal string PublisherCredential { get; }
|
||||||
|
internal string ReadOnlyOperatorCredential { get; }
|
||||||
|
internal string FullOperatorCredential { get; }
|
||||||
|
|
||||||
|
internal static async Task<OperatorTestHost> StartAsync()
|
||||||
|
{
|
||||||
|
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||||
|
EphemeralStoreOptions stateOptions = new();
|
||||||
|
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||||
|
SigningKeyOptions publisherKey = ProvisioningTestData.CreateKey();
|
||||||
|
SigningKeyOptions operatorKey = ProvisioningTestData.CreateKey(
|
||||||
|
OperatorKeyId,
|
||||||
|
"operator-secret",
|
||||||
|
credentialKinds: [PrincipalCredentialKind.Operator],
|
||||||
|
gameId: null,
|
||||||
|
environmentId: null);
|
||||||
|
ProvisioningOptions options = ProvisioningTestData.CreateOptions();
|
||||||
|
options.SigningKeys = [publisherKey, operatorKey];
|
||||||
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||||
|
options,
|
||||||
|
ProvisioningTestData.CreateSecrets("secret-1", "operator-secret"),
|
||||||
|
clock.UtcNow);
|
||||||
|
string publisherCredential = provisioning.Credentials.Issue(
|
||||||
|
ProvisioningTestData.CreateDedicatedPublisher(),
|
||||||
|
clock.UtcNow);
|
||||||
|
string readOnlyCredential = provisioning.Credentials.Issue(
|
||||||
|
new OperatorPrincipal(
|
||||||
|
"operator-readonly",
|
||||||
|
clock.UtcNow.AddMinutes(10),
|
||||||
|
[OperatorPermission.ReadPolicy]),
|
||||||
|
clock.UtcNow);
|
||||||
|
string fullCredential = provisioning.Credentials.Issue(
|
||||||
|
new OperatorPrincipal(
|
||||||
|
"operator-full",
|
||||||
|
clock.UtcNow.AddMinutes(10),
|
||||||
|
Enum.GetValues<OperatorPermission>()),
|
||||||
|
clock.UtcNow);
|
||||||
|
CapturingLogger<AuditTrail> auditLogger = new();
|
||||||
|
CapturingLoggerProvider allLogs = new();
|
||||||
|
EphemeralCapabilityIssuer capabilities = new();
|
||||||
|
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.SetMinimumLevel(LogLevel.Debug);
|
||||||
|
builder.Logging.AddProvider(allLogs);
|
||||||
|
builder.Logging.AddFilter(
|
||||||
|
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
|
||||||
|
LogLevel.None);
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(static json =>
|
||||||
|
ContractJson.Configure(json.SerializerOptions));
|
||||||
|
builder.Services.Configure<RouteHandlerOptions>(static route =>
|
||||||
|
route.ThrowOnBadRequest = true);
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||||
|
builder.Services.AddOptions<AbuseProtectionOptions>().Configure(static abuse =>
|
||||||
|
{
|
||||||
|
abuse.OperatorAllowedAddresses = ["127.0.0.1"];
|
||||||
|
abuse.HttpGlobalRequestsPerWindow = 1;
|
||||||
|
abuse.HttpOptionalRequestsPerWindow = 1;
|
||||||
|
abuse.HttpIpPrefixRequestsPerWindow = 1;
|
||||||
|
abuse.HttpOptionalIpPrefixRequestsPerWindow = 1;
|
||||||
|
});
|
||||||
|
builder.Services.AddOptions<AuditOptions>();
|
||||||
|
builder.Services.AddOptions<UdpMediatorOptions>().Configure(static udp =>
|
||||||
|
{
|
||||||
|
udp.ListenAddress = "127.0.0.1";
|
||||||
|
udp.Port = 0;
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton(provisioning);
|
||||||
|
builder.Services.AddSingleton(provisioning.Policies);
|
||||||
|
builder.Services.AddSingleton(provisioning.Credentials);
|
||||||
|
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||||
|
builder.Services.AddSingleton(store);
|
||||||
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||||
|
builder.Services.AddSingleton<IWallClock>(clock);
|
||||||
|
builder.Services.AddSingleton<IMonotonicClock>(clock);
|
||||||
|
builder.Services.AddSingleton(capabilities);
|
||||||
|
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||||
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||||
|
builder.Services.AddSingleton<JoinAttemptService>();
|
||||||
|
builder.Services.AddSingleton<RendezvousTelemetry>();
|
||||||
|
builder.Services.AddSingleton<AbuseProtectionService>();
|
||||||
|
builder.Services.AddSingleton<NatMediationProcessor>();
|
||||||
|
builder.Services.AddSingleton<UdpMediatorService>();
|
||||||
|
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||||
|
builder.Services.AddSingleton<RendezvousReadiness>();
|
||||||
|
builder.Services.AddSingleton<ILogger<AuditTrail>>(auditLogger);
|
||||||
|
builder.Services.AddSingleton<AuditTrail>();
|
||||||
|
builder.Services.AddSingleton<OperatorService>();
|
||||||
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
app.UseMiddleware<TelemetryMiddleware>();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||||
|
app.MapOperatorEndpoints();
|
||||||
|
app.MapRendezvousHealthEndpoints();
|
||||||
|
app.MapGet("/test/public", static () => Results.Ok()).WithName("TestPublic");
|
||||||
|
app.MapPost(
|
||||||
|
"/test/exception",
|
||||||
|
static IResult () => throw new InvalidOperationException("exception-secret-canary"))
|
||||||
|
.WithName("TestSecretException");
|
||||||
|
await app.StartAsync();
|
||||||
|
IServer server = app.Services.GetRequiredService<IServer>();
|
||||||
|
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||||
|
return new OperatorTestHost(
|
||||||
|
app,
|
||||||
|
new HttpClient { BaseAddress = new Uri(address) },
|
||||||
|
clock,
|
||||||
|
store,
|
||||||
|
app.Services.GetRequiredService<AuditTrail>(),
|
||||||
|
auditLogger,
|
||||||
|
allLogs,
|
||||||
|
publisherCredential,
|
||||||
|
readOnlyCredential,
|
||||||
|
fullCredential);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal SessionListingId CreateListing(string owner)
|
||||||
|
{
|
||||||
|
StoreResult<StoredListing> result = CreateListingResult(owner, out SessionListingId listingId);
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
return listingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal StoreResult<StoredListing> CreateListingResult(
|
||||||
|
string owner,
|
||||||
|
out SessionListingId listingId)
|
||||||
|
{
|
||||||
|
int sequence = Interlocked.Increment(ref _listingSequence);
|
||||||
|
listingId = new(Guid.NewGuid());
|
||||||
|
StoreResult<StoredListing> result = Store.CreateListing(new(
|
||||||
|
$"operator-listing-{sequence}",
|
||||||
|
$"operator-request-{sequence}",
|
||||||
|
new ListingDefinition
|
||||||
|
{
|
||||||
|
ListingId = listingId,
|
||||||
|
LeaseId = new(Guid.NewGuid()),
|
||||||
|
Scope = new(new GameId("space-game"), new EnvironmentId("production")),
|
||||||
|
OwnerSubject = owner,
|
||||||
|
RegionId = new("eu-central"),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
BuildVersion = "1.0.0",
|
||||||
|
DisplayName = "Operator test listing",
|
||||||
|
Visibility = ListingVisibility.Public,
|
||||||
|
TrustMode = PublisherTrustMode.ManagedDedicated,
|
||||||
|
CurrentPlayers = 1,
|
||||||
|
MaximumPlayers = 4,
|
||||||
|
Metadata = new Dictionary<string, string> { ["mode"] = "online-coop" },
|
||||||
|
LeaseFingerprint = new("lease-fingerprint"),
|
||||||
|
HostPresenceHandle = new(Guid.NewGuid()),
|
||||||
|
HostPresenceFingerprint = new("presence-fingerprint"),
|
||||||
|
CapabilityDerivationSalt = new string('A', 43),
|
||||||
|
}));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task StartUdpAsync()
|
||||||
|
{
|
||||||
|
await _application.Services.GetRequiredService<UdpMediatorService>()
|
||||||
|
.StartAsync(CancellationToken.None);
|
||||||
|
_udpStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Client.Dispose();
|
||||||
|
if (_udpStarted)
|
||||||
|
{
|
||||||
|
await _application.Services.GetRequiredService<UdpMediatorService>()
|
||||||
|
.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
await _application.StopAsync();
|
||||||
|
await _application.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user