Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08729ae25c |
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitea
|
||||
.idea
|
||||
.vs
|
||||
.codex
|
||||
.agents
|
||||
**/bin
|
||||
**/obj
|
||||
TestResults
|
||||
deploy/compose/secrets
|
||||
deploy/compose/.smoke.env
|
||||
docs
|
||||
tests
|
||||
@@ -88,3 +88,60 @@ jobs:
|
||||
else
|
||||
echo "Network namespaces/NAT tooling unavailable; deterministic loopback topology remains the required gate."
|
||||
fi
|
||||
|
||||
container:
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install .NET SDK
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.301
|
||||
|
||||
- name: Build deployment diagnostic
|
||||
run: |
|
||||
dotnet restore src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj --locked-mode
|
||||
dotnet build src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj --configuration Release --no-restore
|
||||
|
||||
- name: Build and exercise hardened container
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose_file="deploy/compose/compose.yaml"
|
||||
secret="deploy/compose/secrets/signing-key"
|
||||
cleanup() {
|
||||
RENDEZVOUS_UID=1654 RENDEZVOUS_GID=1654 \
|
||||
docker compose -f "$compose_file" down --volumes >/dev/null 2>&1 || true
|
||||
rm -f "$secret"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
install -d -m 0700 deploy/compose/secrets
|
||||
openssl rand -out "$secret" 32
|
||||
chmod 0444 "$secret"
|
||||
export RENDEZVOUS_UID=1654
|
||||
export RENDEZVOUS_GID=1654
|
||||
docker compose -f "$compose_file" up --build --detach
|
||||
container_id="$(docker compose -f "$compose_file" ps -q rendezvous)"
|
||||
test -n "$container_id"
|
||||
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
|
||||
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container_id")" = "true"
|
||||
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/app/appsettings.Production.json\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
||||
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/run/secrets/rendezvous-signing-key\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
|
||||
for attempt in {1..100}; do
|
||||
if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if (( attempt == 100 )); then
|
||||
docker compose -f "$compose_file" logs rendezvous
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
./scripts/smoke-deployment.sh
|
||||
docker compose -f "$compose_file" stop --timeout 40 rendezvous
|
||||
test "$(docker inspect --format '{{.State.Running}}' "$container_id")" = "false"
|
||||
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = "0"
|
||||
|
||||
@@ -6,3 +6,6 @@ TestResults/
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
deploy/compose/.smoke.env
|
||||
deploy/compose/secrets/*
|
||||
!deploy/compose/secrets/.gitignore
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS build
|
||||
|
||||
WORKDIR /source
|
||||
COPY Directory.Build.props Directory.Packages.props NuGet.config global.json Rendezvous.slnx ./
|
||||
COPY src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj src/FinalFactory.Rendezvous.Contracts/packages.lock.json src/FinalFactory.Rendezvous.Contracts/
|
||||
COPY src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj src/FinalFactory.Rendezvous.Server/packages.lock.json src/FinalFactory.Rendezvous.Server/
|
||||
RUN dotnet restore src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj --locked-mode
|
||||
|
||||
COPY src/FinalFactory.Rendezvous.Contracts/ src/FinalFactory.Rendezvous.Contracts/
|
||||
COPY src/FinalFactory.Rendezvous.Server/ src/FinalFactory.Rendezvous.Server/
|
||||
RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj \
|
||||
--configuration Release \
|
||||
--no-restore \
|
||||
--output /out \
|
||||
/p:UseAppHost=false \
|
||||
/p:OpenApiGenerateDocuments=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0.9-noble-chiseled@sha256:f820c4fbfb8bb204c3bbe05c69d48cd039cd0e67aa8f13ac1cec168819b90643 AS runtime
|
||||
|
||||
ENV ASPNETCORE_HTTP_PORTS=8080 \
|
||||
DOTNET_EnableDiagnostics=0 \
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||
TMPDIR=/tmp
|
||||
WORKDIR /app
|
||||
COPY --from=build --chown=1654:1654 /out/ ./
|
||||
USER 1654:1654
|
||||
EXPOSE 8080/tcp
|
||||
EXPOSE 9050/udp
|
||||
ENTRYPOINT ["dotnet", "FinalFactory.Rendezvous.Server.dll"]
|
||||
@@ -38,7 +38,11 @@ UDP hole punching cannot guarantee a direct connection through every network. Sy
|
||||
- `FinalFactory.Rendezvous.TestClient` — thin interactive and scriptable host/browser/join diagnostic built only on the public SDK.
|
||||
- `FinalFactory.Rendezvous.Tests` — unit, integration, security, and connection-lifecycle tests.
|
||||
|
||||
The server directory and NAT mediator begin as separate modules in one deployable service because they share session, lease, authorization, and endpoint state. Their internal boundary should allow independent deployment later if scale, availability, or security requirements diverge.
|
||||
The server directory and NAT mediator are separate modules in one single-active
|
||||
deployable service because they share ephemeral session, lease, authorization,
|
||||
replay, and endpoint state. Their internal boundary can support a future
|
||||
explicitly designed shared-state architecture; operators must not create
|
||||
multiple active v1 replicas.
|
||||
|
||||
## Service boundaries
|
||||
|
||||
@@ -77,9 +81,11 @@ The initial service does not provide:
|
||||
|
||||
Rendezvous is under active roadmap development. The versioned contracts,
|
||||
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
|
||||
SDK coordination, typed connection outcomes, and thin public-SDK diagnostic client
|
||||
are implemented. Deployment hardening, the broader NAT-topology harness, and
|
||||
the production-readiness roadmap remain in progress;
|
||||
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
|
||||
deterministic NAT topology harness, hostile-input controls,
|
||||
observability/operator surface, and secure single-active Linux deployment are
|
||||
implemented. Capacity, resilience, packaging, and final production-readiness
|
||||
gates remain in progress;
|
||||
participating games must not treat the current repository as a finished production
|
||||
service until those gates land.
|
||||
|
||||
@@ -94,6 +100,9 @@ defined in [hostile-input and overload protection](docs/security/abuse-protectio
|
||||
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
|
||||
operator controls are defined in the
|
||||
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
|
||||
The pinned non-root container, production topology, graceful drain, Linux
|
||||
hardening, smoke procedure, and recovery lifecycle are documented in
|
||||
[secure single-active Linux deployment](docs/deployment/linux.md).
|
||||
The scriptable host/browser/join diagnostic and its stable automation contract are
|
||||
documented in the [TestClient integration guide](docs/integration/test-client.md).
|
||||
The always-on three-party scenarios, optional Linux namespace topology, and
|
||||
@@ -117,8 +126,9 @@ Run the bootstrap server with
|
||||
the configured UDP mediator port; both stop through normal host cancellation.
|
||||
The launch profile uses separate ephemeral development-only publisher and operator
|
||||
signing keys. Production
|
||||
startup fails closed until externally supplied game policies and `env:` signing
|
||||
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.
|
||||
The project dependency rules and supported runtime choices are documented in
|
||||
[project and dependency boundaries](docs/architecture/project-boundaries.md).
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"AllowedHosts": "localhost;127.0.0.1",
|
||||
"Rendezvous": {
|
||||
"Deployment": {
|
||||
"PublicHttpBaseUrl": "https://localhost/",
|
||||
"PublicUdpHost": "127.0.0.1",
|
||||
"PublicUdpPort": 9050,
|
||||
"DrainDeadlineSeconds": 30,
|
||||
"MinimumDrainSeconds": 1,
|
||||
"SingleActiveInstance": true,
|
||||
"AllowPrivatePublicEndpoints": true
|
||||
},
|
||||
"Udp": {
|
||||
"ListenAddress": "0.0.0.0",
|
||||
"Port": 9050
|
||||
},
|
||||
"AbuseProtection": {
|
||||
"TrustedProxyAddresses": ["127.0.0.1"],
|
||||
"OperatorAllowedAddresses": ["127.0.0.1"]
|
||||
},
|
||||
"Provisioning": {
|
||||
"Issuer": "final-factory-rendezvous-smoke",
|
||||
"Audience": "rendezvous-service",
|
||||
"ClockSkewSeconds": 30,
|
||||
"SigningKeys": [
|
||||
{
|
||||
"KeyId": "local-smoke-1",
|
||||
"SecretReference": "file:/run/secrets/rendezvous-signing-key",
|
||||
"CredentialKinds": ["DedicatedPublisher"],
|
||||
"GameId": "space-game",
|
||||
"EnvironmentId": "smoke",
|
||||
"NotBefore": "2026-01-01T00:00:00Z",
|
||||
"SignUntil": "2100-01-01T00:00:00Z",
|
||||
"VerifyUntil": "2100-01-02T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"Games": [
|
||||
{
|
||||
"GameId": "space-game",
|
||||
"EnvironmentId": "smoke",
|
||||
"Enabled": true,
|
||||
"ProtocolVersions": [1],
|
||||
"Regions": ["local"],
|
||||
"VisibilityModes": ["Public"],
|
||||
"PublisherTrustModes": ["ManagedDedicated"],
|
||||
"MetadataValueMaxBytes": {},
|
||||
"RequiredMetadataKeys": [],
|
||||
"MetadataMaxBytes": 512,
|
||||
"MetadataMaxKeys": 0,
|
||||
"MaxListingsPerPrincipal": 10,
|
||||
"MaxAnonymousListingsPerAddress": 0,
|
||||
"MaxActiveJoinAttempts": 100,
|
||||
"FallbackPolicy": "Disabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
name: rendezvous-local
|
||||
|
||||
services:
|
||||
rendezvous:
|
||||
image: finalfactory/rendezvous:local
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
init: true
|
||||
user: "${RENDEZVOUS_UID:?set RENDEZVOUS_UID to a non-root host UID}:${RENDEZVOUS_GID:?set RENDEZVOUS_GID to its GID}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=16m,uid=${RENDEZVOUS_UID},gid=${RENDEZVOUS_GID},mode=0700
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
pids_limit: 128
|
||||
mem_limit: 512m
|
||||
cpus: 1.0
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 4096
|
||||
hard: 4096
|
||||
stop_grace_period: 40s
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
ASPNETCORE_HTTP_PORTS: "8080"
|
||||
volumes:
|
||||
- ./appsettings.Production.json:/app/appsettings.Production.json:ro
|
||||
- ./secrets/signing-key:/run/secrets/rendezvous-signing-key:ro
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080/tcp"
|
||||
- "9050:9050/udp"
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,47 @@
|
||||
[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
|
||||
MemoryMax=512M
|
||||
TasksMax=128
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,225 @@
|
||||
# Secure single-active Linux deployment
|
||||
|
||||
Tracking: #17
|
||||
|
||||
Rendezvous v1 stores listings, observed endpoints, join attempts, replay markers,
|
||||
and runtime revocations only in the process that accepted them. Deploy exactly
|
||||
one active instance. A second live replica would have a different directory and
|
||||
replay boundary; `SingleActiveInstance=false` is therefore rejected rather than
|
||||
presented as high availability.
|
||||
|
||||
## Pinned container
|
||||
|
||||
The root `Dockerfile` uses a multi-stage .NET 10 build and pins both Microsoft
|
||||
base images by multi-architecture manifest digest. The runtime is the chiseled
|
||||
ASP.NET image, contains only the published server, runs as UID/GID 1654, exposes
|
||||
TCP 8080 and UDP 9050 explicitly, and does not require a writable application
|
||||
directory. Supply a small writable `/tmp` tmpfs because runtime libraries can
|
||||
legitimately need temporary space; keep the root filesystem read-only.
|
||||
|
||||
From a clean checkout:
|
||||
|
||||
```bash
|
||||
docker build --pull=false --tag finalfactory/rendezvous:local .
|
||||
docker inspect --format '{{.Config.User}}' finalfactory/rendezvous:local
|
||||
```
|
||||
|
||||
The reported user must be `1654:1654`. Digest pins make a rebuild reproducible;
|
||||
updating .NET is an explicit reviewed change to the tag, digest, SDK pin, and
|
||||
lock files together. Do not replace the digest with `latest` in production.
|
||||
|
||||
The local Compose example applies a read-only root, non-root user, no Linux
|
||||
capabilities, `no-new-privileges`, bounded PIDs/files/memory/CPU, and a shutdown
|
||||
grace period longer than the service drain deadline:
|
||||
|
||||
```bash
|
||||
install -d -m 0700 deploy/compose/secrets
|
||||
umask 077
|
||||
openssl rand -out deploy/compose/secrets/signing-key 32
|
||||
export RENDEZVOUS_UID="$(id -u)"
|
||||
export RENDEZVOUS_GID="$(id -g)"
|
||||
test "$RENDEZVOUS_UID" -ne 0
|
||||
docker compose -f deploy/compose/compose.yaml up --build --detach
|
||||
```
|
||||
|
||||
`deploy/compose/appsettings.Production.json` is an isolated loopback smoke
|
||||
profile, not an Internet template: it deliberately opts into private advertised
|
||||
endpoints and has no TLS proxy. Its random key is ignored by Git and must be
|
||||
deleted after use. Its deliberately long key window only keeps this disposable
|
||||
local fixture usable; production keys require short, reviewed rotation windows.
|
||||
Production configuration must use its real public names and must leave
|
||||
`AllowPrivatePublicEndpoints` false.
|
||||
|
||||
## Production topology
|
||||
|
||||
Use one active service behind a source-preserving edge:
|
||||
|
||||
```text
|
||||
clients -- HTTPS/443 --> TLS reverse proxy -- HTTP/8080 --> Rendezvous
|
||||
clients -- UDP/9050 -------------------------------------> Rendezvous
|
||||
```
|
||||
|
||||
- Give the HTTPS origin and UDP endpoint stable DNS names. Set
|
||||
`PublicHttpBaseUrl` to the exact external HTTPS origin and `PublicUdpHost` /
|
||||
`PublicUdpPort` to the endpoint given to game clients.
|
||||
- Terminate TLS 1.2 or newer at a maintained reverse proxy. Bind internal HTTP
|
||||
only to the private proxy network. Restrict `AllowedHosts` to the public HTTP
|
||||
host; wildcard host filtering is rejected.
|
||||
- Put only the proxy's exact literal addresses in
|
||||
`Rendezvous:AbuseProtection:TrustedProxyAddresses`. Rendezvous ignores
|
||||
forwarded headers from every other source. Keep the last proxy from replacing
|
||||
the original client address and prevent direct access to TCP 8080.
|
||||
- Forward UDP as UDP, without an HTTP proxy. NAT, load balancer, firewall, and
|
||||
return routing must preserve the client's source IP/port and must send replies
|
||||
from the same advertised IP/port. Many HTTP load balancers, Kubernetes ingress
|
||||
controllers, rootless container port proxies, anycast products, and generic
|
||||
L7 services cannot guarantee this. Do not deploy through one unless an actual
|
||||
host/join smoke proves both observed source and reply path. A load balancer
|
||||
must have exactly one healthy Rendezvous target.
|
||||
- Permit inbound TCP 443 to the TLS proxy and UDP 9050 to Rendezvous. Permit the
|
||||
proxy to reach TCP 8080. Permit DNS, time synchronization, image/telemetry
|
||||
destinations as required by local policy, and UDP replies to client endpoints.
|
||||
Deny public TCP 8080 and every unused inbound port.
|
||||
|
||||
Readiness is the load-balancer gate; liveness is only a process-health signal.
|
||||
Remove a draining instance from new traffic when `/health/ready` becomes 503.
|
||||
Do not use liveness failure to start a second active process while the old one
|
||||
still owns the public UDP address.
|
||||
|
||||
## Required production configuration
|
||||
|
||||
Production startup validates all of these before binding listeners:
|
||||
|
||||
- an absolute path-free HTTPS `PublicHttpBaseUrl`;
|
||||
- an unambiguous public `PublicUdpHost` and port;
|
||||
- `SingleActiveInstance=true`, an explicit non-wildcard `AllowedHosts`, and at
|
||||
least one exact trusted TLS-proxy address;
|
||||
- a 1-30 second drain deadline whose minimum observation interval is shorter;
|
||||
- at least one enabled game policy and an active scoped signing key.
|
||||
|
||||
Missing values produce an actionable startup error. The checked-in base file is
|
||||
intentionally unsafe for Production so an accidental bare launch fails closed.
|
||||
|
||||
Signing keys support two external references:
|
||||
|
||||
- `env:NAME` reads 1-4096 bytes encoded as base64 from `NAME`;
|
||||
- `file:/absolute/path` reads 1-4096 raw bytes from a non-symlink file.
|
||||
|
||||
Prefer a read-only container secret owned by the configured container identity.
|
||||
For systemd, use a root-owned, `rendezvous`-group-owned `0440` file (or an
|
||||
equivalent narrow ACL) so the non-root process can read but not replace it. A
|
||||
signing key must contain at least 32 random bytes. Never put the key, publisher/operator
|
||||
credential, or secret value in JSON, a command argument, an image layer, Compose
|
||||
environment, logs, metrics, or source control. Configuration contains only the
|
||||
reference and non-secret lifecycle metadata. A vault/KMS adapter can replace the
|
||||
provider where local policy requires it.
|
||||
|
||||
Keep the host clock synchronized with authenticated NTP. Credential and key
|
||||
windows use wall time; lease, timeout, drain, and rate-limit deadlines use a
|
||||
monotonic clock. Alert on clock synchronization loss before rotating keys.
|
||||
|
||||
Start with the Compose limits (one CPU, 512 MiB, 128 PIDs, 4096 descriptors),
|
||||
measure real traffic, then change the limits and the 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 same resource, filesystem, privilege, network-family, and
|
||||
shutdown constraints as Compose.
|
||||
|
||||
## HTTP and UDP smoke
|
||||
|
||||
Build the diagnostic once, then exercise the actual published HTTP and UDP
|
||||
paths. The test creates a public listing, sends authenticated presence and punch
|
||||
traffic through UDP 9050, establishes peer-to-peer traffic, reports the outcome,
|
||||
and deregisters cleanly:
|
||||
|
||||
```bash
|
||||
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
||||
./scripts/smoke-deployment.sh
|
||||
```
|
||||
|
||||
For the local Compose profile, the script derives a ten-minute diagnostic
|
||||
publisher credential from the ignored local key without printing either secret.
|
||||
For production, do not copy the signing key to the smoke host. Instead inject a
|
||||
short-lived, region-scoped credential through
|
||||
`RENDEZVOUS_PUBLISHER_CREDENTIAL`, and set the external endpoints:
|
||||
|
||||
```bash
|
||||
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<short-lived deployment credential>'
|
||||
export RENDEZVOUS_SMOKE_HTTP_URL='https://rendezvous.your-company.tld/'
|
||||
export RENDEZVOUS_SMOKE_UDP_ENDPOINT='rendezvous-udp.your-company.tld:9050'
|
||||
export RENDEZVOUS_SMOKE_GAME_ID='<credential game ID>'
|
||||
export RENDEZVOUS_SMOKE_ENVIRONMENT_ID='<credential environment ID>'
|
||||
export RENDEZVOUS_SMOKE_REGION='<credential region>'
|
||||
export RENDEZVOUS_SMOKE_PROTOCOL_VERSION='<enabled protocol version>'
|
||||
./scripts/smoke-deployment.sh
|
||||
```
|
||||
|
||||
Those four scope values must match both the short-lived credential and an
|
||||
enabled server policy. The defaults (`space-game`, `smoke`, `local`, protocol
|
||||
`1`) are only for the checked-in local Compose profile.
|
||||
|
||||
The smoke fails unless both health endpoints and the complete authenticated UDP
|
||||
mediation/direct-traffic flow succeed. It does not prove every consumer NAT;
|
||||
run the topology harness and representative external-network tests as well.
|
||||
|
||||
## Restart, upgrade, rollback, and backup
|
||||
|
||||
Rendezvous has no durable runtime database. Restarting intentionally loses all
|
||||
listings, observed endpoints, attempts, replay markers, and runtime-only
|
||||
revocations. Hosts must treat registration as a renewable lease and re-register
|
||||
after service recovery. Clients must re-browse and start a new bounded attempt.
|
||||
|
||||
Back up only reviewed configuration, policy, secret references, key material and
|
||||
its custody/lifecycle records, deployment manifests, and image digest. Never
|
||||
claim a backup contains live sessions or endpoints. Restore keys only through the
|
||||
secret system, not into the image or repository.
|
||||
|
||||
For an upgrade:
|
||||
|
||||
1. Build and test the new pinned digest; validate configuration without starting
|
||||
a second active instance.
|
||||
2. Drain and stop the current process, verify both sockets are released, then
|
||||
start the replacement on the same public endpoints.
|
||||
3. Require live/readiness and HTTP+UDP smoke success; monitor host
|
||||
re-registration, error rate, and direct-connect outcomes.
|
||||
|
||||
For rollback, repeat the same stop-before-start sequence with the previously
|
||||
recorded image digest and compatible configuration/key set. Never run old and
|
||||
new versions concurrently to avoid split ephemeral state. If a wire-incompatible
|
||||
change ever becomes necessary, use a new API/protocol version rather than a
|
||||
rolling two-version replica set.
|
||||
@@ -63,8 +63,9 @@ only its public key ID/lifecycle metadata and does not require retired secret
|
||||
material to remain available.
|
||||
|
||||
Key IDs are non-secret base64url identifiers. Secret references are resolved
|
||||
through `ISecretProvider`; production supports `env:<VARIABLE>` references and
|
||||
the interface is replaceable by a deployment-specific vault/KMS adapter. The
|
||||
through `ISecretProvider`; production supports base64 `env:<VARIABLE>` and raw
|
||||
`file:/absolute/path` references to bounded non-symlink files. The interface is
|
||||
replaceable by a deployment-specific vault/KMS adapter. The
|
||||
committed development profile uses an in-memory random key identified by a
|
||||
`development:ephemeral/...` reference. It never writes key material to disk and
|
||||
all credentials become invalid when the process exits.
|
||||
@@ -74,8 +75,10 @@ all credentials become invalid when the process exits.
|
||||
`Rendezvous:Provisioning` supplies issuer, audience, clock skew, signing-key
|
||||
descriptors, and game policies. A production key reference such as
|
||||
`env:RENDEZVOUS_SIGNING_KEY_2026_01` expects that environment variable to hold at
|
||||
least 32 random bytes encoded as base64. Missing, malformed, short, inactive, or
|
||||
duplicate keys stop startup with a key-ID-only diagnostic. No game-wide secret
|
||||
least 32 random bytes encoded as base64. `file:/run/secrets/rendezvous-signing`
|
||||
expects the raw bytes in a read-only, absolute, non-symlink file. Missing,
|
||||
malformed, short, inactive, or duplicate keys stop startup with a key-ID-only
|
||||
diagnostic. No game-wide secret
|
||||
belongs in `appsettings`, source control, examples, the Client package, URLs,
|
||||
responses, logs, metrics, exceptions, or diagnostic dumps.
|
||||
|
||||
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SERVICE_URL="${RENDEZVOUS_SMOKE_HTTP_URL:-http://127.0.0.1:8080/}"
|
||||
MEDIATOR="${RENDEZVOUS_SMOKE_UDP_ENDPOINT:-127.0.0.1:9050}"
|
||||
TIMEOUT_SECONDS="${RENDEZVOUS_SMOKE_TIMEOUT_SECONDS:-30}"
|
||||
LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}"
|
||||
PROJECT="$ROOT/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj"
|
||||
BUILD_CONFIGURATION="${RENDEZVOUS_SMOKE_CONFIGURATION:-Release}"
|
||||
GAME_ID="${RENDEZVOUS_SMOKE_GAME_ID:-space-game}"
|
||||
ENVIRONMENT_ID="${RENDEZVOUS_SMOKE_ENVIRONMENT_ID:-smoke}"
|
||||
REGION="${RENDEZVOUS_SMOKE_REGION:-local}"
|
||||
PROTOCOL_VERSION="${RENDEZVOUS_SMOKE_PROTOCOL_VERSION:-1}"
|
||||
|
||||
for command in curl date dotnet jq mktemp od openssl tail tr wc; do
|
||||
command -v "$command" >/dev/null || {
|
||||
printf 'Missing required command: %s\n' "$command" >&2
|
||||
exit 2
|
||||
}
|
||||
done
|
||||
|
||||
if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || (( TIMEOUT_SECONDS < 1 || TIMEOUT_SECONDS > 300 )); then
|
||||
printf 'RENDEZVOUS_SMOKE_TIMEOUT_SECONDS must be an integer from 1 through 300.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$PROTOCOL_VERSION" =~ ^[0-9]+$ ]] || (( PROTOCOL_VERSION < 1 )); then
|
||||
printf 'RENDEZVOUS_SMOKE_PROTOCOL_VERSION must be a positive integer.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
for scoped_value in "$GAME_ID" "$ENVIRONMENT_ID" "$REGION"; do
|
||||
if [[ -z "$scoped_value" ]]; then
|
||||
printf 'Smoke game, environment, and region values must not be empty.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
base64url() {
|
||||
openssl base64 -A | tr '+/' '-_' | tr -d '='
|
||||
}
|
||||
|
||||
local_credential() {
|
||||
if [[ ! -f "$LOCAL_KEY" ]] || [[ "$(wc -c < "$LOCAL_KEY")" -ne 32 ]]; then
|
||||
printf 'Local Compose smoke key must be exactly 32 bytes: %s\n' "$LOCAL_KEY" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
local now expires nonce payload encoded signed hex signature
|
||||
now="$(date +%s)"
|
||||
expires="$((now + 600))"
|
||||
nonce="$(openssl rand -hex 16)"
|
||||
payload="$(jq -cn \
|
||||
--arg issuer final-factory-rendezvous-smoke \
|
||||
--arg audience rendezvous-service \
|
||||
--arg subject local-smoke-host \
|
||||
--arg kind dedicatedPublisher \
|
||||
--arg gameId "$GAME_ID" \
|
||||
--arg environmentId "$ENVIRONMENT_ID" \
|
||||
--arg region "$REGION" \
|
||||
--arg nonce "$nonce" \
|
||||
--argjson now "$now" \
|
||||
--argjson expires "$expires" \
|
||||
'{version:1,issuer:$issuer,audience:$audience,subject:$subject,kind:$kind,gameId:$gameId,environmentId:$environmentId,regions:[$region],permissions:[],issuedAtUnixSeconds:$now,notBeforeUnixSeconds:$now,expiresAtUnixSeconds:$expires,nonce:$nonce}')"
|
||||
encoded="$(printf '%s' "$payload" | base64url)"
|
||||
signed="rv1.local-smoke-1.$encoded"
|
||||
hex="$(od -An -v -tx1 "$LOCAL_KEY" | tr -d ' \n')"
|
||||
signature="$(printf '%s' "$signed" \
|
||||
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$hex" -binary \
|
||||
| base64url)"
|
||||
printf '%s.%s' "$signed" "$signature"
|
||||
}
|
||||
|
||||
credential="${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}"
|
||||
if [[ -z "$credential" ]]; then
|
||||
credential="$(local_credential)"
|
||||
fi
|
||||
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$credential"
|
||||
|
||||
curl --fail --silent --show-error --max-time 5 "${SERVICE_URL%/}/health/live" >/dev/null
|
||||
curl --fail --silent --show-error --max-time 5 "${SERVICE_URL%/}/health/ready" >/dev/null
|
||||
|
||||
temp_dir="$(mktemp -d)"
|
||||
host_log="$temp_dir/host.jsonl"
|
||||
join_log="$temp_dir/join.jsonl"
|
||||
host_pid=''
|
||||
cleanup() {
|
||||
local status="$?"
|
||||
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
|
||||
kill -TERM "$host_pid" 2>/dev/null || true
|
||||
wait "$host_pid" 2>/dev/null || true
|
||||
fi
|
||||
if [[ "$status" -ne 0 ]]; then
|
||||
printf 'Deployment smoke failed; sanitized diagnostic events follow.\n' >&2
|
||||
[[ -f "$host_log" ]] && jq -c . "$host_log" >&2 || true
|
||||
[[ -f "$join_log" ]] && jq -c . "$join_log" >&2 || true
|
||||
fi
|
||||
rm -rf "$temp_dir"
|
||||
return "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-build -- \
|
||||
host --service "$SERVICE_URL" --mediator "$MEDIATOR" \
|
||||
--game "$GAME_ID" --environment "$ENVIRONMENT_ID" --region "$REGION" --protocol "$PROTOCOL_VERSION" \
|
||||
--script --json --exit-after-echo --timeout-seconds "$TIMEOUT_SECONDS" \
|
||||
>"$host_log" 2>&1 &
|
||||
host_pid="$!"
|
||||
|
||||
ready=false
|
||||
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
|
||||
if jq -e 'select(.event == "host.ready")' "$host_log" >/dev/null 2>&1; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$host_pid" 2>/dev/null; then
|
||||
printf 'Host diagnostic stopped before it became ready.\n' >&2
|
||||
jq -c . "$host_log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
if [[ "$ready" != true ]]; then
|
||||
printf 'Host diagnostic did not become ready within %s seconds.\n' "$TIMEOUT_SECONDS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
listing_id="$(jq -r 'select(.event == "host.registered") | .listingId' "$host_log" | tail -n 1)"
|
||||
if [[ -z "$listing_id" || "$listing_id" == null ]]; then
|
||||
printf 'Host diagnostic did not report a listing ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-build -- \
|
||||
join --service "$SERVICE_URL" --mediator "$MEDIATOR" \
|
||||
--game "$GAME_ID" --environment "$ENVIRONMENT_ID" --region "$REGION" --protocol "$PROTOCOL_VERSION" \
|
||||
--listing "$listing_id" --script --json --timeout-seconds "$TIMEOUT_SECONDS" \
|
||||
>"$join_log" 2>&1
|
||||
wait "$host_pid"
|
||||
host_pid=''
|
||||
|
||||
jq -e 'select(.event == "host.direct-traffic" and .status == "verified")' "$host_log" >/dev/null
|
||||
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$host_log" >/dev/null
|
||||
jq -e 'select(.event == "join.direct-traffic" and .status == "verified")' "$join_log" >/dev/null
|
||||
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$join_log" >/dev/null
|
||||
|
||||
printf 'Rendezvous deployment smoke passed: HTTP live/ready and authenticated UDP mediation/direct traffic.\n'
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Deployment;
|
||||
|
||||
internal sealed record DeploymentOptions
|
||||
{
|
||||
public const string SectionName = "Rendezvous:Deployment";
|
||||
|
||||
[Required]
|
||||
public string PublicHttpBaseUrl { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string PublicUdpHost { get; init; } = string.Empty;
|
||||
|
||||
[Range(1, 65_535)]
|
||||
public int PublicUdpPort { get; init; } = 9050;
|
||||
|
||||
[Range(1, 30)]
|
||||
public int DrainDeadlineSeconds { get; init; } = 30;
|
||||
|
||||
[Range(0, 5)]
|
||||
public int MinimumDrainSeconds { get; init; } = 1;
|
||||
|
||||
public bool SingleActiveInstance { get; init; } = true;
|
||||
|
||||
public bool AllowPrivatePublicEndpoints { get; init; }
|
||||
|
||||
public IReadOnlyList<string> ValidateProduction(
|
||||
AbuseProtectionOptions abuseProtection,
|
||||
string? allowedHosts)
|
||||
{
|
||||
List<string> errors = [];
|
||||
if (!SingleActiveInstance)
|
||||
{
|
||||
errors.Add("Rendezvous:Deployment:SingleActiveInstance must be true because ephemeral state is not shared between replicas.");
|
||||
}
|
||||
|
||||
if (MinimumDrainSeconds >= DrainDeadlineSeconds)
|
||||
{
|
||||
errors.Add("Rendezvous:Deployment:MinimumDrainSeconds must be less than DrainDeadlineSeconds.");
|
||||
}
|
||||
|
||||
if (DrainDeadlineSeconds is < 1 or > 30)
|
||||
{
|
||||
errors.Add("Rendezvous:Deployment:DrainDeadlineSeconds must be between 1 and 30.");
|
||||
}
|
||||
|
||||
if (MinimumDrainSeconds is < 0 or > 5)
|
||||
{
|
||||
errors.Add("Rendezvous:Deployment:MinimumDrainSeconds must be between 0 and 5.");
|
||||
}
|
||||
|
||||
if (PublicUdpPort is < 1 or > 65_535)
|
||||
{
|
||||
errors.Add("Rendezvous:Deployment:PublicUdpPort must be between 1 and 65535.");
|
||||
}
|
||||
|
||||
ValidateHttpEndpoint(errors);
|
||||
ValidateUdpEndpoint(errors);
|
||||
ValidateAllowedHosts(errors, allowedHosts, PublicHttpBaseUrl);
|
||||
|
||||
if (abuseProtection.TrustedProxyAddresses is not { Length: > 0 })
|
||||
{
|
||||
errors.Add(
|
||||
"Rendezvous:AbuseProtection:TrustedProxyAddresses must list the exact TLS proxy addresses; forwarded headers are rejected without this trust boundary.");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void ValidateHttpEndpoint(List<string> errors)
|
||||
{
|
||||
if (!Uri.TryCreate(PublicHttpBaseUrl, UriKind.Absolute, out Uri? endpoint)
|
||||
|| !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
|
||||
|| !string.IsNullOrEmpty(endpoint.UserInfo)
|
||||
|| !string.IsNullOrEmpty(endpoint.Query)
|
||||
|| !string.IsNullOrEmpty(endpoint.Fragment)
|
||||
|| endpoint.AbsolutePath != "/")
|
||||
{
|
||||
errors.Add(
|
||||
"Rendezvous:Deployment:PublicHttpBaseUrl must be an absolute HTTPS origin with no credentials, path, query, or fragment (for example, https://rendezvous.your-company.tld/).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AllowPrivatePublicEndpoints && !IsPublicHost(endpoint.Host))
|
||||
{
|
||||
errors.Add(
|
||||
"Rendezvous:Deployment:PublicHttpBaseUrl must use a public DNS name or address; set AllowPrivatePublicEndpoints=true only for an isolated deployment smoke test.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateUdpEndpoint(List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PublicUdpHost)
|
||||
|| PublicUdpHost.Contains("//", StringComparison.Ordinal)
|
||||
|| PublicUdpHost.Contains(':', StringComparison.Ordinal) && !IPAddress.TryParse(PublicUdpHost, out _)
|
||||
|| Uri.CheckHostName(PublicUdpHost) == UriHostNameType.Unknown)
|
||||
{
|
||||
errors.Add(
|
||||
"Rendezvous:Deployment:PublicUdpHost must contain only the advertised DNS name or IP address; configure the port separately.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AllowPrivatePublicEndpoints && !IsPublicHost(PublicUdpHost))
|
||||
{
|
||||
errors.Add(
|
||||
"Rendezvous:Deployment:PublicUdpHost must use a public DNS name or address; set AllowPrivatePublicEndpoints=true only for an isolated deployment smoke test.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAllowedHosts(
|
||||
List<string> errors,
|
||||
string? allowedHosts,
|
||||
string publicHttpBaseUrl)
|
||||
{
|
||||
string[] hosts = (allowedHosts ?? string.Empty).Split(
|
||||
';',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (hosts.Length == 0 || hosts.Any(static host => host is "*" or "+"))
|
||||
{
|
||||
errors.Add(
|
||||
"AllowedHosts must explicitly list the public HTTP host in production; wildcard or empty host filtering is unsafe.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(publicHttpBaseUrl, UriKind.Absolute, out Uri? endpoint)
|
||||
&& !hosts.Contains(endpoint.Host, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(
|
||||
"AllowedHosts must contain the exact host advertised by Rendezvous:Deployment:PublicHttpBaseUrl.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPublicHost(string host)
|
||||
{
|
||||
if (!IPAddress.TryParse(host, out IPAddress? address))
|
||||
{
|
||||
return Uri.CheckHostName(host) == UriHostNameType.Dns
|
||||
&& host.Contains('.', StringComparison.Ordinal)
|
||||
&& !IsReservedDnsName(host);
|
||||
}
|
||||
|
||||
return IsGloballyRoutableUnicast(address);
|
||||
}
|
||||
|
||||
private static bool IsReservedDnsName(string host)
|
||||
{
|
||||
string normalized = host.TrimEnd('.');
|
||||
string[] reservedSuffixes =
|
||||
[
|
||||
"localhost",
|
||||
"local",
|
||||
"invalid",
|
||||
"test",
|
||||
"example",
|
||||
"example.com",
|
||||
"example.net",
|
||||
"example.org",
|
||||
"home.arpa",
|
||||
"alt",
|
||||
"onion",
|
||||
];
|
||||
return reservedSuffixes.Any(suffix =>
|
||||
string.Equals(normalized, suffix, StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.EndsWith($".{suffix}", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsGloballyRoutableUnicast(IPAddress address)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
return !(bytes[0] is 0 or 10 or 127
|
||||
|| bytes[0] == 100 && bytes[1] is >= 64 and <= 127
|
||||
|| bytes[0] == 169 && bytes[1] == 254
|
||||
|| bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|
||||
|| bytes[0] == 192
|
||||
&& (bytes[1] == 0 && bytes[2] is 0 or 2
|
||||
|| bytes[1] == 88 && bytes[2] == 99
|
||||
|| bytes[1] == 168)
|
||||
|| bytes[0] == 198
|
||||
&& (bytes[1] is 18 or 19
|
||||
|| bytes[1] == 51 && bytes[2] == 100)
|
||||
|| bytes[0] == 203 && bytes[1] == 0 && bytes[2] == 113
|
||||
|| bytes[0] >= 224);
|
||||
}
|
||||
|
||||
return address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6
|
||||
&& !IPAddress.IsLoopback(address)
|
||||
&& !address.Equals(IPAddress.IPv6Any)
|
||||
&& !address.IsIPv6LinkLocal
|
||||
&& !address.IsIPv6SiteLocal
|
||||
&& !address.IsIPv6Multicast
|
||||
&& (bytes[0] & 0xe0) == 0x20
|
||||
&& !HasPrefix(bytes, [0x20, 0x01, 0x00], 23)
|
||||
&& !HasPrefix(bytes, [0x20, 0x01, 0x0d, 0xb8], 32)
|
||||
&& !HasPrefix(bytes, [0x20, 0x02], 16)
|
||||
&& !HasPrefix(bytes, [0x3f, 0xff, 0x00], 20);
|
||||
}
|
||||
|
||||
private static bool HasPrefix(byte[] address, byte[] prefix, int bitCount)
|
||||
{
|
||||
int fullBytes = bitCount / 8;
|
||||
for (int index = 0; index < fullBytes; index++)
|
||||
{
|
||||
if (address[index] != prefix[index])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int remainingBits = bitCount % 8;
|
||||
if (remainingBits == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
int mask = 0xff << (8 - remainingBits);
|
||||
return (address[fullBytes] & mask) == (prefix[fullBytes] & mask);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DeploymentConfigurationException(IReadOnlyList<string> errors)
|
||||
: InvalidOperationException(
|
||||
"Production deployment configuration is invalid:" + Environment.NewLine
|
||||
+ string.Join(Environment.NewLine, errors.Select(static error => $"- {error}")))
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Diagnostics;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Deployment;
|
||||
|
||||
internal sealed partial class GracefulDrainService : IHostedService, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||
private readonly InMemoryEphemeralRendezvousStore _store;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly DeploymentOptions _options;
|
||||
private readonly ILogger<GracefulDrainService> _logger;
|
||||
private readonly object _gate = new();
|
||||
private CancellationTokenRegistration _stoppingRegistration;
|
||||
private Task? _drainTask;
|
||||
|
||||
public GracefulDrainService(
|
||||
InMemoryEphemeralRendezvousStore store,
|
||||
IHostApplicationLifetime lifetime,
|
||||
IOptions<DeploymentOptions> options,
|
||||
ILogger<GracefulDrainService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_lifetime = lifetime;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_stoppingRegistration = _lifetime.ApplicationStopping.Register(
|
||||
() => EnsureDrainAsync().GetAwaiter().GetResult());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// ApplicationStopping callbacks run before hosted services and listeners
|
||||
// stop. StopAsync is the idempotent fallback for directly driven hosts.
|
||||
_ = cancellationToken;
|
||||
return EnsureDrainAsync();
|
||||
}
|
||||
|
||||
public void Dispose() => _stoppingRegistration.Dispose();
|
||||
|
||||
private Task EnsureDrainAsync()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _drainTask ??= DrainAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DrainAsync()
|
||||
{
|
||||
_store.BeginDrain(CancellationToken.None);
|
||||
TimeSpan deadline = TimeSpan.FromSeconds(_options.DrainDeadlineSeconds);
|
||||
TimeSpan minimum = TimeSpan.FromSeconds(_options.MinimumDrainSeconds);
|
||||
long startedAt = Stopwatch.GetTimestamp();
|
||||
LogDrainStarted(_logger, _options.DrainDeadlineSeconds);
|
||||
try
|
||||
{
|
||||
while (Stopwatch.GetElapsedTime(startedAt) < deadline)
|
||||
{
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(startedAt);
|
||||
if (elapsed >= minimum && _store.GetActiveJoinAttemptCountForDrain() == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
TimeSpan remaining = deadline - elapsed;
|
||||
await Task.Delay(
|
||||
remaining < PollInterval ? remaining : PollInterval,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_store.MarkUnavailable();
|
||||
double elapsedMilliseconds = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
||||
LogDrainFinished(_logger, elapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Graceful drain started with a {DrainDeadlineSeconds}-second deadline")]
|
||||
private static partial void LogDrainStarted(ILogger logger, int drainDeadlineSeconds);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Graceful drain finished after {ElapsedMilliseconds:F0} ms; ephemeral state was cleared")]
|
||||
private static partial void LogDrainFinished(ILogger logger, double elapsedMilliseconds);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||
using FinalFactory.Rendezvous.Server.Deployment;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Observability;
|
||||
@@ -235,8 +236,29 @@ AbuseProtectionOptions configuredAbuseProtection = builder.Configuration
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
TrustedProxyForwarding.Configure(options, configuredAbuseProtection));
|
||||
|
||||
DeploymentOptions deploymentOptions = builder.Configuration
|
||||
.GetSection(DeploymentOptions.SectionName)
|
||||
.Get<DeploymentOptions>() ?? new DeploymentOptions();
|
||||
if (!builder.Environment.IsDevelopment() && !isOpenApiGeneration)
|
||||
{
|
||||
IReadOnlyList<string> deploymentErrors = deploymentOptions.ValidateProduction(
|
||||
configuredAbuseProtection,
|
||||
builder.Configuration["AllowedHosts"]);
|
||||
if (deploymentErrors.Count > 0)
|
||||
{
|
||||
throw new DeploymentConfigurationException(deploymentErrors);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(deploymentOptions));
|
||||
builder.Services.Configure<HostOptions>(options =>
|
||||
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
|
||||
|
||||
SystemRendezvousClock rendezvousClock = new();
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
EphemeralStoreOptions stateOptions = new()
|
||||
{
|
||||
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
|
||||
};
|
||||
InMemoryEphemeralRendezvousStore stateStore = new(
|
||||
stateOptions,
|
||||
rendezvousClock,
|
||||
@@ -304,10 +326,12 @@ if (!isOpenApiGeneration)
|
||||
builder.Services.AddSingleton<NatMediationProcessor>();
|
||||
builder.Services.AddHostedService(static services =>
|
||||
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();
|
||||
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
||||
|
||||
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
|
||||
{
|
||||
|
||||
@@ -40,18 +40,32 @@ internal sealed class SecretMaterial : IDisposable
|
||||
|
||||
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)
|
||||
{
|
||||
secret = null;
|
||||
if (!reference.StartsWith(Prefix, StringComparison.Ordinal)
|
||||
|| reference.Length == Prefix.Length)
|
||||
if (reference.StartsWith(EnvironmentPrefix, StringComparison.Ordinal)
|
||||
&& 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))
|
||||
{
|
||||
return false;
|
||||
@@ -60,6 +74,12 @@ internal sealed class EnvironmentSecretProvider : ISecretProvider
|
||||
try
|
||||
{
|
||||
byte[] bytes = Convert.FromBase64String(encoded);
|
||||
if (bytes.Length is 0 or > MaximumSecretBytes)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
return false;
|
||||
}
|
||||
|
||||
secret = new SecretMaterial(bytes);
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
return true;
|
||||
@@ -69,6 +89,47 @@ internal sealed class EnvironmentSecretProvider : ISecretProvider
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
|
||||
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
|
||||
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
|
||||
private readonly PriorityQueue<AttemptExpiry, long> _attemptExpiries = new();
|
||||
private readonly Dictionary<JoinAttemptId, OutcomeReportEntry> _outcomeReports = [];
|
||||
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
|
||||
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
|
||||
@@ -82,6 +83,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
}
|
||||
|
||||
internal int GetActiveJoinAttemptCountForDrain()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_expiryChurn += RemoveExpiredAttempts(_monotonicClock.Elapsed);
|
||||
return _attempts.Count;
|
||||
}
|
||||
}
|
||||
|
||||
internal EphemeralStoreSnapshot GetMetricsSnapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -451,6 +461,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
now + _options.JoinAttemptLifetime,
|
||||
WallDeadline(now, _options.JoinAttemptLifetime));
|
||||
_attempts.Add(command.AttemptId, attempt);
|
||||
_attemptExpiries.Enqueue(
|
||||
new AttemptExpiry(command.AttemptId, attempt.Deadline),
|
||||
attempt.Deadline.Ticks);
|
||||
_outcomeReports.Add(command.AttemptId, new(
|
||||
command.ListingId,
|
||||
command.ClientSubject,
|
||||
@@ -920,15 +933,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
_presence.Remove(handle);
|
||||
}
|
||||
|
||||
JoinAttemptId[] expiredAttempts = _attempts
|
||||
.Where(item => item.Value.Deadline <= now)
|
||||
.Select(static item => item.Key)
|
||||
.ToArray();
|
||||
_expiryChurn += expiredAttempts.Length;
|
||||
foreach (JoinAttemptId attemptId in expiredAttempts)
|
||||
{
|
||||
RemoveAttempt(attemptId);
|
||||
}
|
||||
_expiryChurn += RemoveExpiredAttempts(now);
|
||||
|
||||
JoinAttemptId[] expiredOutcomes = _outcomeReports
|
||||
.Where(item => item.Value.Deadline <= now)
|
||||
@@ -958,6 +963,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
_presenceHandles.Clear();
|
||||
_presence.Clear();
|
||||
_attempts.Clear();
|
||||
_attemptExpiries.Clear();
|
||||
_outcomeReports.Clear();
|
||||
_attemptHandles.Clear();
|
||||
_idempotency.Clear();
|
||||
@@ -1000,6 +1006,24 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
}
|
||||
|
||||
private int RemoveExpiredAttempts(TimeSpan now)
|
||||
{
|
||||
int removed = 0;
|
||||
while (_attemptExpiries.TryPeek(out AttemptExpiry candidate, out long deadlineTicks)
|
||||
&& deadlineTicks <= now.Ticks)
|
||||
{
|
||||
_attemptExpiries.Dequeue();
|
||||
if (_attempts.TryGetValue(candidate.AttemptId, out AttemptEntry? current)
|
||||
&& current.Deadline == candidate.Deadline)
|
||||
{
|
||||
RemoveAttempt(candidate.AttemptId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
private bool HandleExists(MediationHandle handle) =>
|
||||
_presenceHandles.ContainsKey(handle) || _attemptHandles.ContainsKey(handle);
|
||||
|
||||
@@ -1195,6 +1219,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
public bool IsCancelled { get; set; }
|
||||
}
|
||||
|
||||
private readonly record struct AttemptExpiry(JoinAttemptId AttemptId, TimeSpan Deadline);
|
||||
|
||||
private sealed class OutcomeReportEntry(
|
||||
SessionListingId listingId,
|
||||
string clientSubject,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
{
|
||||
"Rendezvous": {
|
||||
"Deployment": {
|
||||
"PublicHttpBaseUrl": "",
|
||||
"PublicUdpHost": "",
|
||||
"PublicUdpPort": 9050,
|
||||
"DrainDeadlineSeconds": 30,
|
||||
"MinimumDrainSeconds": 1,
|
||||
"SingleActiveInstance": true,
|
||||
"AllowPrivatePublicEndpoints": false
|
||||
},
|
||||
"Udp": {
|
||||
"ListenAddress": "0.0.0.0",
|
||||
"Port": 9050,
|
||||
|
||||
@@ -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,458 @@
|
||||
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);
|
||||
}
|
||||
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 DocumentedSmokeScriptReachesHttpAndAuthenticatedUdpFlow()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string root = RepositoryRoot();
|
||||
int httpPort = ReserveTcpPort();
|
||||
int udpPort = ReserveUdpPort();
|
||||
string secretPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"rendezvous-smoke-secret-{Guid.NewGuid():N}");
|
||||
await File.WriteAllBytesAsync(secretPath, RandomNumberGenerator.GetBytes(32));
|
||||
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: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);
|
||||
}
|
||||
}
|
||||
|
||||
[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,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
|
||||
public sealed class ProductionSecretProviderTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _paths = [];
|
||||
|
||||
[Fact]
|
||||
public void ReadsBoundedSecretFromAbsoluteReadOnlyFile()
|
||||
{
|
||||
byte[] expected = RandomNumberGenerator.GetBytes(32);
|
||||
string path = CreateSecretFile(expected);
|
||||
EnvironmentSecretProvider provider = new();
|
||||
|
||||
bool found = provider.TryGetSecret($"file:{path}", out SecretMaterial? material);
|
||||
|
||||
Assert.True(found);
|
||||
using (material)
|
||||
{
|
||||
Assert.Equal(expected, material!.CopyBytes());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsRelativeSymlinkEmptyAndOversizedFileReferences()
|
||||
{
|
||||
string empty = CreateSecretFile([]);
|
||||
string oversized = CreateSecretFile(new byte[4097]);
|
||||
string target = CreateSecretFile(RandomNumberGenerator.GetBytes(32));
|
||||
string symlink = Path.Combine(Path.GetTempPath(), $"rendezvous-secret-link-{Guid.NewGuid():N}");
|
||||
EnvironmentSecretProvider provider = new();
|
||||
|
||||
Assert.False(provider.TryGetSecret("file:relative-secret", out _));
|
||||
Assert.False(provider.TryGetSecret($"file:{empty}", out _));
|
||||
Assert.False(provider.TryGetSecret($"file:{oversized}", out _));
|
||||
Assert.False(provider.TryGetSecret("file:/tmp/invalid\0path", out _));
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.CreateSymbolicLink(symlink, target);
|
||||
_paths.Add(symlink);
|
||||
Assert.False(provider.TryGetSecret($"file:{symlink}", out _));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string path in _paths)
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateSecretFile(byte[] bytes)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"rendezvous-secret-{Guid.NewGuid():N}");
|
||||
File.WriteAllBytes(path, bytes);
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD())
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead);
|
||||
}
|
||||
_paths.Add(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -666,15 +666,30 @@ public sealed class TestClientProcessIntegrationTests
|
||||
private static Dictionary<string, string> ServerEnvironment(
|
||||
string signingKey,
|
||||
DateTimeOffset now,
|
||||
string listenAddress)
|
||||
string listenAddress,
|
||||
string? readinessAddress)
|
||||
{
|
||||
string advertisedAddress = readinessAddress ?? listenAddress;
|
||||
string allowedHosts = string.Join(
|
||||
';',
|
||||
new[] { advertisedAddress, listenAddress, "127.0.0.1", "localhost" }
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase));
|
||||
Dictionary<string, string> values = new(StringComparer.Ordinal)
|
||||
{
|
||||
["ASPNETCORE_ENVIRONMENT"] = "Production",
|
||||
["ASPNETCORE_URLS"] = $"http://{listenAddress}:0",
|
||||
["AllowedHosts"] = allowedHosts,
|
||||
["Rendezvous__Deployment__PublicHttpBaseUrl"] = $"https://{advertisedAddress}/",
|
||||
["Rendezvous__Deployment__PublicUdpHost"] = advertisedAddress,
|
||||
["Rendezvous__Deployment__PublicUdpPort"] = "9050",
|
||||
["Rendezvous__Deployment__DrainDeadlineSeconds"] = "3",
|
||||
["Rendezvous__Deployment__MinimumDrainSeconds"] = "1",
|
||||
["Rendezvous__Deployment__SingleActiveInstance"] = "true",
|
||||
["Rendezvous__Deployment__AllowPrivatePublicEndpoints"] = "true",
|
||||
["Rendezvous__Udp__ListenAddress"] = listenAddress,
|
||||
["Rendezvous__Udp__Port"] = "0",
|
||||
["Rendezvous__Udp__PollIntervalMilliseconds"] = "1",
|
||||
["Rendezvous__AbuseProtection__TrustedProxyAddresses__0"] = "127.0.0.1",
|
||||
["Rendezvous__Provisioning__Issuer"] = "rendezvous-process-test",
|
||||
["Rendezvous__Provisioning__Audience"] = "rendezvous-process-test-client",
|
||||
["Rendezvous__Provisioning__ClockSkewSeconds"] = "5",
|
||||
@@ -1346,7 +1361,11 @@ public sealed class TestClientProcessIntegrationTests
|
||||
string signingKeyText = Convert.ToBase64String(signingKey);
|
||||
string publisherCredential = IssuePublisherCredential(signingKey, now);
|
||||
CryptographicOperations.ZeroMemory(signingKey);
|
||||
Dictionary<string, string> environment = ServerEnvironment(signingKeyText, now, listenAddress);
|
||||
Dictionary<string, string> environment = ServerEnvironment(
|
||||
signingKeyText,
|
||||
now,
|
||||
listenAddress,
|
||||
readinessAddress);
|
||||
ProcessCapture server = processNamespace is null
|
||||
? Start(serverAssembly, [], environment)
|
||||
: StartInNamespace(processNamespace, serverAssembly, [], environment);
|
||||
|
||||
Reference in New Issue
Block a user