FishMMO Patch Server (ASP.NET)
Table of Contents
- Overview
- Supported Platforms
- Architecture
- Directory Structure
- Middleware Pipeline
- Endpoints
- Key Components
- Configuration
- Security
- External Dependencies
- Requirements
- Flow Diagram
Overview
ASP.NET Core patch delivery server for FishMMO clients. Determines the latest available patch version by scanning the patch directory on startup (with FileSystemWatcher hot-reload), then serves versioned .zip patch files to clients that are behind the current version. Access is gated through ClientGate HMAC-signed request validation middleware shared across all FishMMO web services.
Designed to run behind NGINX as a reverse proxy (via api.fishmmo.com). NGINX terminates SSL and forwards requests over plain HTTP to Kestrel on localhost.
Supported Platforms
| Target | Status |
|---|---|
| .NET 8.0 — Linux | Yes (recommended) |
| .NET 8.0 — Windows | Yes |
| .NET 8.0 — macOS | Yes |
| Requirement | Version |
|---|---|
| .NET SDK | 8.0+ |
Patches/ directory |
Required, populated by PatchGenerator |
| NGINX | Recommended for production |
Architecture
Unity Client
|
v HTTPS (api.fishmmo.com/latest_version or api.fishmmo.com/{version})
+---------+
| NGINX | <- SSL termination, X-Forwarded-For/Proto
+----+----+
| HTTP (localhost:8090)
+----v-----------------------------------------+
| Kestrel (Patcher) |
| +-- Exception handler middleware |
| +-- ForwardedHeaders middleware |
| +-- Null-IP rejection middleware |
| +-- FishMMOSecurityHeaders middleware |
| +-- ClientGate (HMAC, key from DB) |
| +-- CORS (Public) |
| +-- Rate Limiting (two-tier) |
| +-- PatchController |
| +-- PatchVersionService |
| | +-- VersionConfig parser |
| | +-- FileSystemWatcher |
| | +-- HMAC signing (from gate key) |
| +-- Patches/ directory (zips) |
+----------------------------------------------+
Directory Structure
Patcher/
├── Program.cs # Host builder, Kestrel config, middleware pipeline
├── Controllers/
│ └── PatchController.cs # GET /latest_version, GET /{version}
├── Services/
│ └── PatchVersionService.cs # Startup patch directory scanner, SHA-256 indexing, FileSystemWatcher
├── VersionConfig.cs # SemVer parser with pre-release support and comparison operators
├── appsettings.json # Port, patch directory configuration
└── (ClientGate is in FishMMO-WebShared/)
Middleware Pipeline
UseExceptionHandler— catches unhandled exceptions, returns structured error responses.UseForwardedHeaders— trustsX-Forwarded-For/X-Forwarded-Protofrom NGINX.- Null-IP rejection middleware — returns 400 if
RemoteIpAddressis null after forwarding (proxy misconfiguration guard). UseFishMMOSecurityHeaders— addsX-Content-Type-Options,X-Frame-Options,Referrer-Policy, etc.UseFishMMOClientGate— validatesX-FishMMO-ClientHMAC-signed header (sharedClientGatemiddleware from FishMMO-WebShared). The shared secret is loaded from thedeployment_secretsdatabase table at startup (viaIDeploymentSecretService+GateSecretHolder) — no environment variable or configuration file fallback. Loopback paths (/healthz) are exempted for monitoring.UseCors("Public")— allows cross-origin requests fromplay.fishmmo.com.UseRateLimiter— two-tier: token bucket (10 req/s, 30 burst) for metadata endpoints; sliding window (6 permits/60s) for patch downloads.UseRouting+MapControllers— standard ASP.NET routing.MapHealthChecks—/healthzendpoint with patch version status.
Endpoints
GET /latest_version · HEAD /latest_version
Metadata endpoint. Returns the latest patch version, and — when the caller identifies its own version — whether a patch path exists for it.
Without ?from=:
{ "latest_version": "1.2.3" }
With ?from={clientVersion}, one of three shapes:
{ "latest_version": "1.2.3", "up_to_date": true }
{ "latest_version": "1.2.3", "patch_available": false }
{ "latest_version": "1.2.3", "patch_available": true, "sha256": "<hex>", "size": 10485760 }
The client uses this to distinguish "you are current", "you are behind and we
have a patch for you" (with the digest and byte size it should expect), and
"you are behind but no patch upgrades your version" — the last of which is
not retryable and drives the launcher's PatchUnavailable state.
Caching and integrity headers:
| Header | Meaning |
|---|---|
ETag |
Weak ETag derived from the answer (W/"<sha256>" when a patch is indexed, otherwise a version-derived token). New patches invalidate it immediately. |
Cache-Control |
public, max-age=30 — launchers polling on a tight loop do not hammer the origin. |
X-FishMMO-Version-Signature |
HMAC-SHA256 over latest_version=<v>&etag_source=<token>, signed with the gate secret. Lets the client confirm the manifest came from an authentic patcher rather than a MITM proxy or spoofed DNS. |
If-None-Match is honoured (RFC 7232 comma-separated list) and short-circuits
with 304 Not Modified. HEAD emits identical headers with no body.
GET /{version}
Downloads the patch file for upgrading from {version} to the latest version.
Flow:
- Parse client version via
VersionConfig.Parse(version). - Parse server latest version via
VersionConfig.Parse(latest). - If client >= latest: return
204 No Content. - Look for the patch indexed as
{clientVersion}-{latestVersion}.zip. - If found: stream as
application/octet-stream.
This route streams a binary archive and the launcher writes the response body straight to disk without inspecting it. The up-to-date case is therefore a bodiless
204, never a200carrying a JSON status document — such a document would be saved as if it were the patch and handed to the Updater.
Response Codes:
| Code | Condition |
|---|---|
| 200 | Patch file streamed |
| 204 | Client is already at or ahead of the latest version — nothing to download |
| 304 | latest_version only: caller's If-None-Match matches the current ETag |
| 400 | Invalid client version format (or invalid ?from= value) |
| 401 | Missing or invalid X-FishMMO-Client header (from ClientGate middleware) |
| 404 | Patch file not found |
| 429 | Rate limit exceeded (token bucket or sliding window) |
| 500 | Latest version unavailable or malformed |
Key Components
PatchVersionService
Singleton service that scans the Patches/ directory on startup:
- Matches files against regex:
^(\d+\.\d+\.\d+(?:\.[a-zA-Z0-9]+)?)-(\d+\.\d+\.\d+(?:\.[a-zA-Z0-9]+)?)\.zip$ - Parses target versions (second capture group) via
VersionConfig.Parse. - Tracks the highest target version as
LatestVersion. - Falls back to
0.0.0if no valid patch files are found. - HMAC signing key: Receives the gate secret via its constructor (loaded from the
deployment_secretsdatabase table at startup). Derives an HMAC-SHA256 signing key from the first comma-separated key entry to sign version manifest responses viaSignContent(). The Unity client verifies this signature to confirm the response originated from an authentic patcher.
VersionConfig
SemVer-compatible version model with pre-release support:
- Format:
Major.Minor.Patch[.PreRelease](e.g.,1.2.3,1.2.3.alpha) - Comparison:
IComparable<VersionConfig>with full operator overloads (==,!=,<,>,<=,>=) - Pre-release rules: A pre-release version has lower precedence than a normal version (SemVer compliant). Pre-release tags are compared lexicographically.
Patch File Naming Convention
<from_version>-<to_version>.zip
Examples:
1.0.0-1.0.1.zip1.0.0.alpha-1.0.0.beta.zip
Release Signing (Ed25519)
Every JSON payload /latest_version returns carries a signature field, and the client verifies
it before reading any other field — the version, the patch name and the SHA-256 it will check the
download against all come from this document, so an unsigned one is an instruction to the launcher
from whoever answered the request.
This is a two-part deployment. The client half ships already; until the server is given a key, nothing is signed. That state is deliberate rather than fail-closed: making an unkeyed server refuse to serve would have bricked every existing deployment the moment this shipped. It logs loudly on every release build instead, and the build validator warns.
1. Generate a keypair
dotnet run --project Tools/ManifestSigner -- keygen --out-dir ./keys
Writes ./keys/version-manifest-signing.key (base64 private seed, mode 600 where supported) and
./keys/version-manifest-signing.pub. With no --out-dir the keys are printed to stdout and
nothing is written. The private key belongs only on the patch server; it is never committed,
never logged, and never leaves the release host.
2. Configure the server
One of, in order of preference:
| Setting | Notes |
|---|---|
Signing:VersionManifestPrivateKeyFile |
Preferred. Path to a file holding the base64 key. |
FISHMMO_VERSION_MANIFEST_SIGNING_KEY |
Environment variable. |
Signing:VersionManifestPrivateKeyBase64 |
Least preferred — the key ends up in configuration. |
A configured-but-unreadable or malformed key throws rather than quietly downgrading to unsigned.
3. Ship the public half to clients
Put the public key in GeneratedPinSet.VersionManifestPublicKeyBase64
(Assets/Scripts/Client/Security/CertificatePins.generated.cs). It is a separate key from the
certificate-pin manifest key; do not reuse one for both.
Rollout order
- Deploy the signing server first, with the key configured.
- Then ship clients carrying the public key.
Clients without the public key configured do not verify, so the reverse order — clients first — means every one of them fails closed against a server that is not yet signing. To check a document the server produced, save the response and verify it locally:
curl -s https://your-host/latest_version > latest.json
dotnet run --project Tools/ManifestSigner -- verify --public-file ./keys/version-manifest-signing.pub --in latest.json
# exit code 0 = valid, 1 = invalid
To sign a manifest offline, note that the output is the signed artifact — sign re-serialises
into the canonical spacing, so deploy the file it writes, not the one you fed it:
dotnet run --project Tools/ManifestSigner -- sign --key ./keys/version-manifest-signing.key --in manifest.json --out manifest.signed.json
Why this exists. The verifier previously appended the signature to the message being signed, which requires solving
sig = Sign(sk, stripped ‖ base64(sig))— a fixed point of a hash-driven function over a 64-byte value, roughly 2^256 work. It was unsatisfiable, and went unnoticed because nothing had ever signed a manifest, so the verifier had never been handed a document that was supposed to pass.ApiPinUpdateSidecarused the identical construction, so certificate pin updates could not have verified either. Both now share the corrected canonical form: the document with itssignaturevalue blanked, compared as received rather than re-serialised, so signer and verifier cannot disagree about key order or spacing.
Configuration
appsettings.json:
{
"WebServer": {
"HttpPort": "8090"
},
"Patches": {
"DirectoryName": "Patches"
}
}
| Key | Default | Purpose |
|---|---|---|
WebServer:HttpPort |
8090 |
Kestrel listen port (localhost only) |
Patches:DirectoryName |
Patches |
Subdirectory containing .zip patch files |
Gate Secret (ClientGate)
The HMAC shared secret for ClientGate request signing and PatchVersionService version-manifest signing is loaded exclusively from the deployment_secrets database table at startup:
- After building the host, the application opens a DI scope and resolves
IDeploymentSecretService. - The service fetches the record with key
"client_gate_secret". - The value is stored in a singleton
GateSecretHolder. - The
Configurecallback readsGateSecretHolder.Secretand passes it toUseFishMMOClientGate(environment, gateSecret, bypassPaths). - The
PatchVersionServiceconstructor also receives the gate secret and derives its HMAC signing key.
Sources: This value is not configurable via environment variables, appsettings.json, command-line arguments, or any other configuration source. The database is the sole source.
Setup: Operators must run the fishmmo-installer → Database → Configure Server Keys workflow to populate it, or insert a row directly:
INSERT INTO deployment_secrets (key, value, created_at, updated_at)
VALUES ('client_gate_secret', 'your-32+byte-secret', NOW(), NOW());
Behaviour if missing: In Production the host refuses to start with a clear error; in Development the gate logs a warning and passes all requests through, so local dev works without a configured database.
Shared secret: The same secret must be deployed to all servers that validate X-FishMMO-Client:
- IPFetchServer (via
GateSecretHolder) - Patcher (via
GateSecretHolder; also used byPatchVersionServiceto HMAC-sign version manifest responses) - Unity client (embedded via Unity Editor: FishMMO Dashboard (
FishMMO > FishMMO Dashboard, or Ctrl+Shift+D) > Game Settings, which writesClientApiSecret.generated.cs)
The client_gate_secret is a single value (or a comma-separated set for rotation). If a comma-separated keyset is provided, all keys are tried during verification so old clients continue to work during rotation.
Security
- ClientGate validates HMAC-SHA256 request signatures with timestamp and nonce replay protection. The shared secret is loaded from the
deployment_secretsdatabase table at startup — not from environment variables or configuration files. - CORS policy (
Public) restricts cross-origin access toplay.fishmmo.com. - ForwardedHeaders ensures correct client IP logging when behind NGINX.
- Rate limiting (two-tier: metadata + download) prevents abuse.
- Kestrel binds to localhost only — not directly accessible from the internet.
- Patch files are served with
FileOptions.SequentialScan, ETag support (conditional GET), and path traversal defense at both index-time and serve-time.
External Dependencies
- FishMMO.Logging - structured async logging.
- Npgsql - PostgreSQL connection (for loading gate secret from
deployment_secretstable viaIDeploymentSecretService).
Requirements
- .NET 8.0 SDK or later
Patches/directory with properly named.zipfiles- Gate secret populated in
deployment_secretsdatabase table (viafishmmo-installer → Database → Configure Server Keys)
Flow Diagram
flowchart TD
Boot[Server start]
Boot --> LoadSecret[Load gate secret\nfrom deployment_secrets DB]
LoadSecret --> Scan[PatchVersionService scans Patches/]
Scan --> Track[Track highest target version as LatestVersion]
Track --> Ready[Ready for requests]
DB[("PostgreSQL\ndeployment_secrets")] -.-> LoadSecret
Client[Unity Client] -->|"GET /latest_version?from=clientVer"| Meta{Compare versions}
Meta -- "client up to date" --> UpToDate["{ latest_version, up_to_date: true }"]
Meta -- "behind, patch indexed" --> HavePatch["{ latest_version, patch_available: true,<br/>sha256, size }"]
Meta -- "behind, no path" --> NoPatch["{ latest_version, patch_available: false }"]
UpToDate --> Signed
HavePatch --> Signed
NoPatch --> Signed
Signed["+ ETag, Cache-Control,<br/>X-FishMMO-Version-Signature"] --> Client
Client -->|GET /clientVersion| Cmp{Client greater or equal to latest?}
Cmp -- yes --> NoContent["204 No Content"]
Cmp -- no --> Lookup["Look for indexed clientVer-latestVer.zip"]
Lookup -->|found| Stream[Stream as application/octet-stream]
Lookup -->|missing| NotFound[404]
Ready -.->|serves both routes| Client
Stream --> Client
NoContent --> Client
NotFound --> Client
- NGINX reverse proxy (recommended for production)