mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>Identifies a specific Rec Room client build.
|
||||
/// <para>
|
||||
/// <see cref="Key"/> is the wire-format identifier the client mod
|
||||
/// stamps on every request via the <c>X-DorkNet-Version</c> header.
|
||||
/// Format is <c>{branch}_{semver}</c>: <c>march_2020_03_10</c>,
|
||||
/// <c>december_2020_12_18</c>. Keep it lowercase, underscore-separated;
|
||||
/// it shows up in admin logs and error responses.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Generation"/> is the wire-format-compatibility group:
|
||||
/// every version inside the same generation shares the same wire
|
||||
/// shapes (JSON DTO casing, route names, etc.) and therefore the same
|
||||
/// strategy implementations. Adding a new build that's protocol-compatible
|
||||
/// with an existing generation = a one-line registration; only a new
|
||||
/// generation entails new strategy code.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ClientVersion(string Key, string Generation)
|
||||
{
|
||||
/// <summary>Sentinel returned when a request has no
|
||||
/// <c>X-DorkNet-Version</c> header AND no
|
||||
/// <c>DorkNet:DefaultClientVersion</c> is configured. Controllers
|
||||
/// can branch on this to either fall through to a sensible default
|
||||
/// or 426 the request.</summary>
|
||||
public static readonly ClientVersion Unknown = new("unknown", "unknown");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>Helpers to read the active <see cref="ClientVersion"/>
|
||||
/// off the current request. The version is set by
|
||||
/// <c>VersionDetectionMiddleware</c> early in the pipeline so any
|
||||
/// downstream consumer (controllers, services, filters) can resolve
|
||||
/// it without re-parsing the header.</summary>
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
private const string ClientVersionKey = "DorkNet.ClientVersion";
|
||||
|
||||
/// <summary>Stamp the resolved version on the request. Called by
|
||||
/// <c>VersionDetectionMiddleware</c>; not for general use.</summary>
|
||||
internal static void SetClientVersion(this HttpContext ctx, ClientVersion v)
|
||||
=> ctx.Items[ClientVersionKey] = v;
|
||||
|
||||
/// <summary>The client version for this request. Returns
|
||||
/// <see cref="ClientVersion.Unknown"/> if the middleware hasn't
|
||||
/// run (e.g. a unit test that calls a controller method directly)
|
||||
/// rather than null, so callers can safely branch on
|
||||
/// <c>Generation</c> without null-checks.</summary>
|
||||
public static ClientVersion ClientVersion(this HttpContext ctx)
|
||||
=> ctx.Items.TryGetValue(ClientVersionKey, out var v)
|
||||
&& v is ClientVersion cv
|
||||
? cv
|
||||
: Compat.ClientVersion.Unknown;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>One client-version-family plugin. Each implementation
|
||||
/// declares which exact version <see cref="VersionKeys"/> it handles
|
||||
/// and which protocol-compatibility <see cref="Generation"/> they map
|
||||
/// to. The plugin's job at registration time is to also wire any
|
||||
/// generation-specific strategy services into DI via
|
||||
/// <see cref="RegisterStrategies"/> so controllers / services
|
||||
/// downstream can resolve the right <c>IWireFormat</c>,
|
||||
/// <c>IRouteOverride</c>, etc. for the active request's generation.
|
||||
///
|
||||
/// <para>Adding a new version with the SAME wire shapes as an existing
|
||||
/// generation = just listing its key in that generation's plugin
|
||||
/// <see cref="VersionKeys"/>. Only a new generation needs new
|
||||
/// strategy classes.</para>
|
||||
/// </summary>
|
||||
public interface IVersionPlugin
|
||||
{
|
||||
/// <summary>All client-build keys this plugin handles. Multiple
|
||||
/// keys can share one plugin when several Rec Room builds shipped
|
||||
/// the same wire format. Convention: lowercase, underscore-separated,
|
||||
/// matching the literal the client mod sends in
|
||||
/// <c>X-DorkNet-Version</c>.</summary>
|
||||
IReadOnlyCollection<string> VersionKeys { get; }
|
||||
|
||||
/// <summary>Wire-format-compatibility group this plugin implements.
|
||||
/// Multiple plugins can share a generation (rare, but allowed when
|
||||
/// e.g. a Quest build and a PC build of the same era are
|
||||
/// protocol-identical and we just want separate
|
||||
/// <see cref="VersionKeys"/> bookkeeping).</summary>
|
||||
string Generation { get; }
|
||||
|
||||
/// <summary>Register any generation-specific strategy services
|
||||
/// into DI. Called once at startup by the bootstrap code in
|
||||
/// <c>Program.cs</c>. Use keyed registrations (keyed by
|
||||
/// <see cref="Generation"/>) so requests for other generations
|
||||
/// don't collide.</summary>
|
||||
void RegisterStrategies(IServiceCollection services);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>Extracts the client version from <c>X-DorkNet-Version</c>
|
||||
/// on every request, validates it against this deployment's allow-list,
|
||||
/// and either:
|
||||
/// <list type="bullet">
|
||||
/// <item>Stamps <see cref="HttpContextExtensions.ClientVersion"/> with
|
||||
/// the resolved <see cref="ClientVersion"/> and forwards the request, OR</item>
|
||||
/// <item>Returns <c>HTTP 426 Upgrade Required</c> with a JSON body
|
||||
/// describing the mismatch when the client's version isn't in
|
||||
/// <see cref="VersionRegistry.SupportedVersionKeys"/>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Headerless requests fall back to
|
||||
/// <see cref="VersionRegistry.DefaultVersionKey"/> when set. That's
|
||||
/// the transition pathway — once every shipped client mod stamps the
|
||||
/// header, set <c>DorkNet:DefaultClientVersion</c> to empty and
|
||||
/// headerless requests get rejected too.</para>
|
||||
///
|
||||
/// <para>Some routes intentionally don't carry a client identity
|
||||
/// (the public-facing site at the apex, the admin SPA at
|
||||
/// <c>admin.{apex}</c>, health checks). Those are excluded by host
|
||||
/// + path prefix below — they always skip the version gate and
|
||||
/// receive <see cref="ClientVersion.Unknown"/>. The apex is whatever
|
||||
/// <see cref="DomainConfig.Apex"/> resolved to at startup, so forks
|
||||
/// running on a different domain get the same exemption automatically.</para>
|
||||
/// </summary>
|
||||
public sealed class VersionDetectionMiddleware(
|
||||
RequestDelegate next,
|
||||
VersionRegistry registry,
|
||||
DomainConfig domain,
|
||||
ILogger<VersionDetectionMiddleware> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace DorkNet.Server.Compat;
|
||||
|
||||
/// <summary>Resolves a client-build key (e.g. <c>december_2020_12_18</c>)
|
||||
/// to its <see cref="ClientVersion"/>. Built once at startup from all
|
||||
/// registered <see cref="IVersionPlugin"/> instances.
|
||||
///
|
||||
/// <para>Singleton — the version-key → generation mapping is fixed at
|
||||
/// boot time, and the registry is read on every request by the
|
||||
/// version-detection middleware.</para>
|
||||
/// </summary>
|
||||
public sealed class VersionRegistry
|
||||
{
|
||||
private readonly Dictionary<string, ClientVersion> _byKey;
|
||||
|
||||
/// <summary>The set of version keys this deployment is willing to
|
||||
/// serve. Comes from <c>DorkNet:SupportedVersions</c> in config.
|
||||
/// Anything else gets a 426 from the middleware.</summary>
|
||||
public IReadOnlySet<string> SupportedVersionKeys { get; }
|
||||
|
||||
/// <summary>Falls back to this when a request arrives without an
|
||||
/// <c>X-DorkNet-Version</c> header — set via
|
||||
/// <c>DorkNet:DefaultClientVersion</c> in config. Empty/null means
|
||||
/// "no fallback; reject headerless requests". Set during the
|
||||
/// transition window where the old client mod doesn't yet stamp
|
||||
/// the header.</summary>
|
||||
public string? DefaultVersionKey { get; }
|
||||
|
||||
public VersionRegistry(
|
||||
IEnumerable<IVersionPlugin> plugins,
|
||||
IReadOnlySet<string> supportedVersionKeys,
|
||||
string? defaultVersionKey)
|
||||
{
|
||||
_byKey = new Dictionary<string, ClientVersion>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
foreach (var key in plugin.VersionKeys)
|
||||
{
|
||||
if (_byKey.ContainsKey(key))
|
||||
throw new InvalidOperationException(
|
||||
$"VersionPlugin collision: '{key}' is claimed by more than one IVersionPlugin. " +
|
||||
$"Each version key maps to exactly one plugin.");
|
||||
_byKey[key] = new ClientVersion(key, plugin.Generation);
|
||||
}
|
||||
}
|
||||
SupportedVersionKeys = supportedVersionKeys;
|
||||
DefaultVersionKey = defaultVersionKey;
|
||||
}
|
||||
|
||||
/// <summary>Resolve a version key (case-insensitive) to its
|
||||
/// <see cref="ClientVersion"/>, or <see cref="ClientVersion.Unknown"/>
|
||||
/// if no plugin claims it. The middleware decides whether unknown
|
||||
/// → reject or → fall back to <see cref="DefaultVersionKey"/>.</summary>
|
||||
public ClientVersion Resolve(string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return ClientVersion.Unknown;
|
||||
return _byKey.TryGetValue(key, out var v) ? v : ClientVersion.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>True when the given version key is in
|
||||
/// <see cref="SupportedVersionKeys"/>. Used by the middleware to
|
||||
/// 426 cross-version connection attempts (e.g. a March client
|
||||
/// hitting a server configured for December only).</summary>
|
||||
public bool IsSupported(string key)
|
||||
=> SupportedVersionKeys.Contains(key);
|
||||
|
||||
/// <summary>Every known plugin-registered version key, for
|
||||
/// diagnostic responses and admin tooling.</summary>
|
||||
public IEnumerable<ClientVersion> AllKnown => _byKey.Values;
|
||||
}
|
||||
Reference in New Issue
Block a user