using System.Text.Json; using Microsoft.AspNetCore.Http; using DorkNet.Server.Services; namespace DorkNet.Server.Compat; /// Extracts the client version from X-DorkNet-Version /// on every request, validates it against this deployment's allow-list, /// and either: /// /// Stamps with /// the resolved and forwards the request, OR /// Returns HTTP 426 Upgrade Required with a JSON body /// describing the mismatch when the client's version isn't in /// . /// /// /// Headerless requests fall back to /// when set. That's /// the transition pathway — once every shipped client mod stamps the /// header, set DorkNet:DefaultClientVersion to empty and /// headerless requests get rejected too. /// /// Some routes intentionally don't carry a client identity /// (the public-facing site at the apex, the admin SPA at /// admin.{apex}, health checks). Those are excluded by host /// + path prefix below — they always skip the version gate and /// receive . The apex is whatever /// resolved to at startup, so forks /// running on a different domain get the same exemption automatically. /// public sealed class VersionDetectionMiddleware( RequestDelegate next, VersionRegistry registry, DomainConfig domain, ILogger logger) { private const string HeaderName = "X-DorkNet-Version"; public async Task InvokeAsync(HttpContext ctx) { if (IsExempt(ctx)) { ctx.SetClientVersion(ClientVersion.Unknown); await next(ctx); return; } var headerVal = ctx.Request.Headers[HeaderName].ToString(); var versionKey = string.IsNullOrWhiteSpace(headerVal) ? registry.DefaultVersionKey : headerVal.Trim(); // No header AND no default configured — reject. Once every // client mod sends the header this is the path that catches // probes / misconfigured clients. if (string.IsNullOrWhiteSpace(versionKey)) { await WriteMismatch(ctx, "client_version_missing", got: null); return; } var resolved = registry.Resolve(versionKey); if (resolved == ClientVersion.Unknown) { // The client claims a version but no plugin knows it. That // means either a typo in the header or a build of Rec Room // we haven't onboarded yet. Either way, reject — server // can't reliably serve a version it doesn't have a // strategy bundle for. await WriteMismatch(ctx, "client_version_unknown", got: versionKey); return; } if (!registry.IsSupported(versionKey)) { // Known version, but this specific deployment is // configured to accept a different set. e.g. a March // client hitting a December-only server. await WriteMismatch(ctx, "client_version_not_accepted", got: versionKey); return; } ctx.SetClientVersion(resolved); await next(ctx); } private bool IsExempt(HttpContext ctx) { // Hosts that don't serve the watch — these are humans on a // browser or admin tooling, not the game client. var host = ctx.Request.Host.Host.ToLowerInvariant(); var apex = domain.Apex.ToLowerInvariant(); if (host == apex || host == "www." + apex) return true; if (host.StartsWith("admin.")) return true; if (host.StartsWith("site.")) return true; if (host.StartsWith("feed.")) return true; // Server health / readiness probes. var path = ctx.Request.Path.Value; if (path is null) return false; if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)) return true; if (path.StartsWith("/api/admin/", StringComparison.OrdinalIgnoreCase)) return true; if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) return true; // The boot-time version gate itself — the whole POINT of this // endpoint is to receive a mismatched-version client and reply // with the watch's native "Update Required" payload. Gating it // here would 426 the request before VersionCheckController // could respond. if (path.StartsWith("/api/versioncheck/", StringComparison.OrdinalIgnoreCase)) return true; return false; } private async Task WriteMismatch(HttpContext ctx, string error, string? got) { logger.LogWarning( "[version-gate] rejecting request path={Path} host={Host} reason={Reason} got={Got} supported=[{Supported}]", ctx.Request.Path, ctx.Request.Host.Host, error, got ?? "(none)", string.Join(",", registry.SupportedVersionKeys)); ctx.Response.StatusCode = StatusCodes.Status426UpgradeRequired; ctx.Response.ContentType = "application/json; charset=utf-8"; var body = new { error, got, supported = registry.SupportedVersionKeys.ToArray(), message = error switch { "client_version_missing" => "Client did not send an X-DorkNet-Version header. Update your DorkNet client mod.", "client_version_unknown" => $"Client claims version '{got}', which no version plugin in this server recognises.", "client_version_not_accepted" => $"Client version '{got}' is known but this deployment is configured to accept only: " + string.Join(", ", registry.SupportedVersionKeys) + ".", _ => error, }, }; await JsonSerializer.SerializeAsync(ctx.Response.Body, body); } }