Table of Contents

FishMMO Patch Server (ASP.NET)

View this file on GitHub

Table of Contents

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

  1. UseExceptionHandler — catches unhandled exceptions, returns structured error responses.
  2. UseForwardedHeaders — trusts X-Forwarded-For / X-Forwarded-Proto from NGINX.
  3. Null-IP rejection middleware — returns 400 if RemoteIpAddress is null after forwarding (proxy misconfiguration guard).
  4. UseFishMMOSecurityHeaders — adds X-Content-Type-Options, X-Frame-Options, Referrer-Policy, etc.
  5. UseFishMMOClientGate — validates X-FishMMO-Client HMAC-signed header (shared ClientGate middleware from FishMMO-WebShared). The shared secret is loaded from the deployment_secrets database table at startup (via IDeploymentSecretService + GateSecretHolder) — no environment variable or configuration file fallback. Loopback paths (/healthz) are exempted for monitoring.
  6. UseCors("Public") — allows cross-origin requests from play.fishmmo.com.
  7. UseRateLimiter — two-tier: token bucket (10 req/s, 30 burst) for metadata endpoints; sliding window (6 permits/60s) for patch downloads.
  8. UseRouting + MapControllers — standard ASP.NET routing.
  9. MapHealthChecks/healthz endpoint 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:

  1. Parse client version via VersionConfig.Parse(version).
  2. Parse server latest version via VersionConfig.Parse(latest).
  3. If client >= latest: return 204 No Content.
  4. Look for the patch indexed as {clientVersion}-{latestVersion}.zip.
  5. 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 a 200 carrying 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.0 if no valid patch files are found.
  • HMAC signing key: Receives the gate secret via its constructor (loaded from the deployment_secrets database table at startup). Derives an HMAC-SHA256 signing key from the first comma-separated key entry to sign version manifest responses via SignContent(). 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.zip
  • 1.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

  1. Deploy the signing server first, with the key configured.
  2. 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. ApiPinUpdateSidecar used the identical construction, so certificate pin updates could not have verified either. Both now share the corrected canonical form: the document with its signature value 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:

  1. After building the host, the application opens a DI scope and resolves IDeploymentSecretService.
  2. The service fetches the record with key "client_gate_secret".
  3. The value is stored in a singleton GateSecretHolder.
  4. The Configure callback reads GateSecretHolder.Secret and passes it to UseFishMMOClientGate(environment, gateSecret, bypassPaths).
  5. The PatchVersionService constructor 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 by PatchVersionService to HMAC-sign version manifest responses)
  • Unity client (embedded via Unity Editor: FishMMO Dashboard (FishMMO > FishMMO Dashboard, or Ctrl+Shift+D) > Game Settings, which writes ClientApiSecret.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_secrets database table at startup — not from environment variables or configuration files.
  • CORS policy (Public) restricts cross-origin access to play.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_secrets table via IDeploymentSecretService).

Requirements

  • .NET 8.0 SDK or later
  • Patches/ directory with properly named .zip files
  • Gate secret populated in deployment_secrets database table (via fishmmo-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)