229 lines
11 KiB
Markdown
229 lines
11 KiB
Markdown
# Secure single-active Linux deployment
|
|
|
|
Tracking: #17
|
|
|
|
Rendezvous v1 stores listings, observed endpoints, join attempts, replay markers,
|
|
and runtime revocations only in the process that accepted them. Deploy exactly
|
|
one active instance. A second live replica would have a different directory and
|
|
replay boundary; `SingleActiveInstance=false` is therefore rejected rather than
|
|
presented as high availability.
|
|
|
|
## Pinned container
|
|
|
|
The root `Dockerfile` uses a multi-stage .NET 10 build and pins both Microsoft
|
|
base images by multi-architecture manifest digest. The runtime is the chiseled
|
|
ASP.NET image, contains only the published server, runs as UID/GID 1654, exposes
|
|
TCP 8080 and UDP 9050 explicitly, and does not require a writable application
|
|
directory. Supply a small writable `/tmp` tmpfs because runtime libraries can
|
|
legitimately need temporary space; keep the root filesystem read-only.
|
|
|
|
From a clean checkout:
|
|
|
|
```bash
|
|
docker build --pull=false --tag finalfactory/rendezvous:local .
|
|
docker inspect --format '{{.Config.User}}' finalfactory/rendezvous:local
|
|
```
|
|
|
|
The reported user must be `1654:1654`. Digest pins make a rebuild reproducible;
|
|
updating .NET is an explicit reviewed change to the tag, digest, SDK pin, and
|
|
lock files together. Do not replace the digest with `latest` in production.
|
|
|
|
The local Compose example applies a read-only root, non-root user, no Linux
|
|
capabilities, `no-new-privileges`, bounded PIDs/files/memory/CPU, and a shutdown
|
|
grace period longer than the service drain deadline:
|
|
|
|
```bash
|
|
install -d -m 0700 deploy/compose/secrets
|
|
umask 077
|
|
openssl rand -out deploy/compose/secrets/signing-key 32
|
|
export RENDEZVOUS_UID="$(id -u)"
|
|
export RENDEZVOUS_GID="$(id -g)"
|
|
test "$RENDEZVOUS_UID" -ne 0
|
|
docker compose -f deploy/compose/compose.yaml up --build --detach
|
|
```
|
|
|
|
`deploy/compose/appsettings.Production.json` is an isolated loopback smoke
|
|
profile, not an Internet template: it deliberately opts into private advertised
|
|
endpoints and has no TLS proxy. Its random key is ignored by Git and must be
|
|
deleted after use. Its deliberately long key window only keeps this disposable
|
|
local fixture usable; production keys require short, reviewed rotation windows.
|
|
Production configuration must use its real public names and must leave
|
|
`AllowPrivatePublicEndpoints` false.
|
|
|
|
## Production topology
|
|
|
|
Use one active service behind a source-preserving edge:
|
|
|
|
```text
|
|
clients -- HTTPS/443 --> TLS reverse proxy -- HTTP/8080 --> Rendezvous
|
|
clients -- UDP/9050 -------------------------------------> Rendezvous
|
|
```
|
|
|
|
- Give the HTTPS origin and UDP endpoint stable DNS names. Set
|
|
`PublicHttpBaseUrl` to the exact external HTTPS origin and `PublicUdpHost` /
|
|
`PublicUdpPort` to the endpoint given to game clients.
|
|
- Terminate TLS 1.2 or newer at a maintained reverse proxy. Bind internal HTTP
|
|
only to the private proxy network. Restrict `AllowedHosts` to the public HTTP
|
|
host; wildcard host filtering is rejected.
|
|
- Put only the proxy's exact literal addresses in
|
|
`Rendezvous:AbuseProtection:TrustedProxyAddresses`. Rendezvous ignores
|
|
forwarded headers from every other source. Keep the last proxy from replacing
|
|
the original client address and prevent direct access to TCP 8080.
|
|
- Forward UDP as UDP, without an HTTP proxy. NAT, load balancer, firewall, and
|
|
return routing must preserve the client's source IP/port and must send replies
|
|
from the same advertised IP/port. Many HTTP load balancers, Kubernetes ingress
|
|
controllers, rootless container port proxies, anycast products, and generic
|
|
L7 services cannot guarantee this. Do not deploy through one unless an actual
|
|
host/join smoke proves both observed source and reply path. A load balancer
|
|
must have exactly one healthy Rendezvous target.
|
|
- Permit inbound TCP 443 to the TLS proxy and UDP 9050 to Rendezvous. Permit the
|
|
proxy to reach TCP 8080. Permit DNS, time synchronization, image/telemetry
|
|
destinations as required by local policy, and UDP replies to client endpoints.
|
|
Deny public TCP 8080 and every unused inbound port.
|
|
|
|
Readiness is the load-balancer gate; liveness is only a process-health signal.
|
|
Remove a draining instance from new traffic when `/health/ready` becomes 503.
|
|
Do not use liveness failure to start a second active process while the old one
|
|
still owns the public UDP address.
|
|
|
|
## Required production configuration
|
|
|
|
Production startup validates all of these before binding listeners:
|
|
|
|
- an absolute path-free HTTPS `PublicHttpBaseUrl`;
|
|
- an unambiguous public `PublicUdpHost` and port;
|
|
- `SingleActiveInstance=true`, an explicit non-wildcard `AllowedHosts`, and at
|
|
least one exact trusted TLS-proxy address;
|
|
- a 1-30 second drain deadline whose minimum observation interval is shorter;
|
|
- at least one enabled game policy and an active scoped signing key.
|
|
|
|
Missing values produce an actionable startup error. The checked-in base file is
|
|
intentionally unsafe for Production so an accidental bare launch fails closed.
|
|
|
|
Signing keys support two external references:
|
|
|
|
- `env:NAME` reads 1-4096 bytes encoded as base64 from `NAME`;
|
|
- `file:/absolute/path` reads 1-4096 raw bytes from a non-symlink file.
|
|
|
|
Prefer a read-only container secret owned by the configured container identity.
|
|
For systemd, use a root-owned, `rendezvous`-group-owned `0440` file (or an
|
|
equivalent narrow ACL) so the non-root process can read but not replace it. A
|
|
signing key must contain at least 32 random bytes. Never put the key, publisher/operator
|
|
credential, or secret value in JSON, a command argument, an image layer, Compose
|
|
environment, logs, metrics, or source control. Configuration contains only the
|
|
reference and non-secret lifecycle metadata. A vault/KMS adapter can replace the
|
|
provider where local policy requires it.
|
|
|
|
Keep the host clock synchronized with authenticated NTP. Credential and key
|
|
windows use wall time; lease, timeout, drain, and rate-limit deadlines use a
|
|
monotonic clock. Alert on clock synchronization loss before rotating keys.
|
|
|
|
The checked-in Compose limits (one CPU and 512 MiB) are for its isolated smoke
|
|
profile, not a production capacity claim. The measured core-state candidate
|
|
uses 2 vCPU and 2 GiB with the same 128-PID/4096-descriptor ceilings; see
|
|
the [capacity and resilience gate](../operations/capacity-and-resilience.md).
|
|
Measure real traffic, then change resource limits and server budgets together.
|
|
Memory pressure or CPU throttling must not extend orchestrator termination past
|
|
`DrainDeadlineSeconds` plus five seconds.
|
|
|
|
## Graceful shutdown
|
|
|
|
SIGTERM and the authenticated operator drain both stop new registrations and
|
|
join attempts immediately. On process shutdown, HTTP and UDP remain available
|
|
long enough for existing join attempts to finish. The service exits as soon as
|
|
the minimum drain interval has elapsed and no attempts remain, or forcibly
|
|
clears all ephemeral state at the configured deadline. It then stops UDP and
|
|
HTTP listeners and exits. Configure Docker/systemd/Kubernetes termination grace
|
|
strictly longer than the service deadline; the examples use 40 seconds for a
|
|
30-second drain.
|
|
|
|
Never use SIGKILL for a normal rollout. After stopping, verify the process is
|
|
gone and neither `8080/tcp` nor `9050/udp` is bound before starting its
|
|
replacement on the same host. A crashed or force-killed process cannot drain;
|
|
clients recover through bounded retries and hosts re-register.
|
|
|
|
## systemd alternative
|
|
|
|
Publish the server for Linux, install the immutable output at `/opt/rendezvous`,
|
|
place production configuration beside the application read-only, place key
|
|
files below `/etc/rendezvous`, and install `deploy/systemd/rendezvous.service`:
|
|
|
|
```bash
|
|
dotnet publish src/FinalFactory.Rendezvous.Server \
|
|
--configuration Release --runtime linux-x64 --self-contained false \
|
|
--output publish/rendezvous
|
|
systemd-analyze verify deploy/systemd/rendezvous.service
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now rendezvous.service
|
|
```
|
|
|
|
Create the dedicated `rendezvous` user without a login shell. Keep
|
|
`/opt/rendezvous` and `/etc/rendezvous` root-owned and non-writable by that user;
|
|
install each required key with `root:rendezvous` ownership and mode `0440`. The
|
|
unit applies the measured 2-vCPU/2-GiB core-state candidate profile plus the
|
|
same filesystem, privilege, network-family, and shutdown hardening as Compose.
|
|
|
|
## HTTP and UDP smoke
|
|
|
|
Build the diagnostic once, then exercise the actual published HTTP and UDP
|
|
paths. The test creates a public listing, sends authenticated presence and punch
|
|
traffic through UDP 9050, establishes peer-to-peer traffic, reports the outcome,
|
|
and deregisters cleanly:
|
|
|
|
```bash
|
|
dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release
|
|
./scripts/smoke-deployment.sh
|
|
```
|
|
|
|
For the local Compose profile, the script derives a ten-minute diagnostic
|
|
publisher credential from the ignored local key without printing either secret.
|
|
For production, do not copy the signing key to the smoke host. Instead inject a
|
|
short-lived, region-scoped credential through
|
|
`RENDEZVOUS_PUBLISHER_CREDENTIAL`, and set the external endpoints:
|
|
|
|
```bash
|
|
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<short-lived deployment credential>'
|
|
export RENDEZVOUS_SMOKE_HTTP_URL='https://rendezvous.your-company.tld/'
|
|
export RENDEZVOUS_SMOKE_UDP_ENDPOINT='rendezvous-udp.your-company.tld:9050'
|
|
export RENDEZVOUS_SMOKE_GAME_ID='<credential game ID>'
|
|
export RENDEZVOUS_SMOKE_ENVIRONMENT_ID='<credential environment ID>'
|
|
export RENDEZVOUS_SMOKE_REGION='<credential region>'
|
|
export RENDEZVOUS_SMOKE_PROTOCOL_VERSION='<enabled protocol version>'
|
|
./scripts/smoke-deployment.sh
|
|
```
|
|
|
|
Those four scope values must match both the short-lived credential and an
|
|
enabled server policy. The defaults (`space-game`, `smoke`, `local`, protocol
|
|
`1`) are only for the checked-in local Compose profile.
|
|
|
|
The smoke fails unless both health endpoints and the complete authenticated UDP
|
|
mediation/direct-traffic flow succeed. It does not prove every consumer NAT;
|
|
run the topology harness and representative external-network tests as well.
|
|
|
|
## Restart, upgrade, rollback, and backup
|
|
|
|
Rendezvous has no durable runtime database. Restarting intentionally loses all
|
|
listings, observed endpoints, attempts, replay markers, and runtime-only
|
|
revocations. Hosts must treat registration as a renewable lease and re-register
|
|
after service recovery. Clients must re-browse and start a new bounded attempt.
|
|
|
|
Back up only reviewed configuration, policy, secret references, key material and
|
|
its custody/lifecycle records, deployment manifests, and image digest. Never
|
|
claim a backup contains live sessions or endpoints. Restore keys only through the
|
|
secret system, not into the image or repository.
|
|
|
|
For an upgrade:
|
|
|
|
1. Build and test the new pinned digest; validate configuration without starting
|
|
a second active instance.
|
|
2. Drain and stop the current process, verify both sockets are released, then
|
|
start the replacement on the same public endpoints.
|
|
3. Require live/readiness and HTTP+UDP smoke success; monitor host
|
|
re-registration, error rate, and direct-connect outcomes.
|
|
|
|
For rollback, repeat the same stop-before-start sequence with the previously
|
|
recorded image digest and compatible configuration/key set. Never run old and
|
|
new versions concurrently to avoid split ephemeral state. If a wire-incompatible
|
|
change ever becomes necessary, use a new API/protocol version rather than a
|
|
rolling two-version replica set.
|