Support local domains and bundled client patching

This commit is contained in:
Alexa
2026-05-27 18:27:09 +03:00
parent ecbf192e47
commit 676dcd09a7
10 changed files with 243 additions and 51 deletions
+2
View File
@@ -1,12 +1,14 @@
## .NET ## .NET
bin/ bin/
obj/ obj/
build/
*.user *.user
*.suo *.suo
.vs/ .vs/
.vscode/ .vscode/
*.csproj.bak *.csproj.bak
dist/ dist/
local-releases/
## Data / secrets ## Data / secrets
# Local SQLite + uploaded blobs only — DON'T match the source-tree # Local SQLite + uploaded blobs only — DON'T match the source-tree
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on # DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
+22 -7
View File
@@ -19,17 +19,32 @@
they would be derivative works of the game anyway, so we never they would be derivative works of the game anyway, so we never
reference them. Only MelonLoader's own DLLs are needed. reference them. Only MelonLoader's own DLLs are needed.
tools/build-patcher-zip.ps1 downloads the MelonLoader release zip By default the project downloads the pinned MelonLoader release
and points MelonLoaderDir at its extracted contents, so a fresh into DorkNet.ClientMod/build/ and references those DLLs. Pass
contributor checkout builds without a local Rec Room install. -p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
deliberately want to use a local override.
To build outside the script (manual dev loop):
dotnet build -p:MelonLoaderDir=<path-to-extracted-MelonLoader>
--> -->
<PropertyGroup> <PropertyGroup>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MSBuildThisFileDirectory)..\build\melonloader</MelonLoaderDir> <MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
</PropertyGroup> </PropertyGroup>
<Target Name="EnsureMelonLoaderReferences"
BeforeTargets="ResolveAssemblyReferences"
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
<DownloadFile
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
DestinationFolder="$(MelonLoaderCacheRoot)"
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
SkipUnchangedFiles="true" />
<Unzip SourceFiles="$(MelonLoaderZip)"
DestinationFolder="$(MelonLoaderCacheRoot)"
OverwriteReadOnlyFiles="true" />
</Target>
<ItemGroup> <ItemGroup>
<Reference Include="MelonLoader"> <Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath> <HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
+109 -9
View File
@@ -42,6 +42,7 @@ public class Mod : MelonMod
internal static string? DiagnosticsLogPath; internal static string? DiagnosticsLogPath;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new(); private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new(); private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _networkPatchesRegistered;
private bool _diagnosticCoreRegistered; private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete; private bool _diagnosticGameComplete;
private int _diagnosticRetryFrame; private int _diagnosticRetryFrame;
@@ -75,6 +76,7 @@ public class Mod : MelonMod
Instance = this; Instance = this;
Log.Msg("=== DorkNet ClientMod loading ==="); Log.Msg("=== DorkNet ClientMod loading ===");
LoadConfig(); LoadConfig();
RegisterNetworkPatches();
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon"); DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
} }
@@ -82,12 +84,7 @@ public class Mod : MelonMod
{ {
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon"); DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
Log.Msg("=== Registering client patches ==="); Log.Msg("=== Registering client patches ===");
TryPatch("Uri ctor (string)", typeof(Uri), "ctor", RegisterNetworkPatches();
new[] { typeof(string) },
prefix: nameof(UriPatches.UriStringCtor_Prefix));
TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
new[] { typeof(string), typeof(UriKind) },
prefix: nameof(UriPatches.UriStringCtor_Prefix));
TryPatchByName("PhotonNetwork", "ConnectUsingSettings", TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix)); prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
TryPatchByName("NetworkingPeer", "CallAuthenticate", TryPatchByName("NetworkingPeer", "CallAuthenticate",
@@ -189,6 +186,57 @@ public class Mod : MelonMod
Log.Msg("=== Client patches registered ==="); Log.Msg("=== Client patches registered ===");
} }
private void RegisterNetworkPatches()
{
if (_networkPatchesRegistered) return;
var complete = true;
complete &= TryPatch("Uri ctor (string)", typeof(Uri), "ctor",
new[] { typeof(string) },
prefix: nameof(UriPatches.UriStringCtor_Prefix));
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
new[] { typeof(string), typeof(UriKind) },
prefix: nameof(UriPatches.UriStringCtor_Prefix));
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
complete &= TryPatchHttpManagerStringSendRequestOverloads();
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
_networkPatchesRegistered = complete;
}
private bool TryPatchHttpManagerStringSendRequestOverloads()
{
var type = ResolveType("BestHTTP.HTTPManager");
if (type is null)
{
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string): type not found");
return false;
}
var prefix = GetPatchMethod(nameof(UriPatches.HttpManagerSendRequestString_Prefix));
var patched = 0;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != "SendRequest") continue;
var parameters = method.GetParameters();
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(string)) continue;
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefix));
patched++;
}
if (patched == 0)
{
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string)");
return false;
}
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
return true;
}
public override void OnUpdate() public override void OnUpdate()
{ {
if (_diagnosticGameComplete) return; if (_diagnosticGameComplete) return;
@@ -464,18 +512,70 @@ public class Mod : MelonMod
internal static class UriPatches internal static class UriPatches
{ {
public static void HttpManagerSendRequestString_Prefix(ref string url)
{
RewriteString(ref url, "httpmanager-rewrite");
}
public static void HttpManagerSendRequestObject_Prefix(object request)
{
RewriteRequestUri(request, "httpmanager-request-rewrite");
}
public static void UriStringCtor_Prefix(ref string uriString) public static void UriStringCtor_Prefix(ref string uriString)
{
RewriteString(ref uriString, "uri-rewrite");
}
public static void HttpRequestSend_Prefix(object __instance)
{
RewriteRequestUri(__instance, "http-rewrite");
}
private static void RewriteRequestUri(object request, string label)
{
try
{
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
if (uriProp?.GetValue(request) is not Uri uri) return;
var rewritten = RewriteUri(uri);
if (rewritten is null) return;
uriProp.SetValue(request, rewritten);
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
}
catch (Exception ex)
{
Mod.Log.Warning($"[{label}] failed: {ex.Message}");
}
}
private static void RewriteString(ref string uriString, string label)
{ {
if (string.IsNullOrEmpty(uriString)) return; if (string.IsNullOrEmpty(uriString)) return;
var host = Mod.Cfg.ServerHost; var host = Mod.Cfg.ServerHost;
if (string.IsNullOrEmpty(host)) return; if (string.IsNullOrEmpty(host)) return;
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return; if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
var rewritten = uriString.Replace(".rec.net", "." + host); var rewritten = uriString.Replace(".rec.net", "." + host);
if (rewritten != uriString) if (rewritten == uriString) return;
{ Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
Mod.Log.Msg($"[uri-rewrite] {uriString} → {rewritten}");
uriString = rewritten; uriString = rewritten;
} }
private static Uri? RewriteUri(Uri uri)
{
var host = Mod.Cfg.ServerHost;
if (string.IsNullOrEmpty(host)) return null;
if (!uri.Host.EndsWith(".rec.net", StringComparison.OrdinalIgnoreCase)) return null;
var service = uri.Host[..^".rec.net".Length];
if (string.IsNullOrWhiteSpace(service)) return null;
return new UriBuilder(uri)
{
Host = service + "." + host,
}.Uri;
} }
} }
@@ -15,5 +15,5 @@ public class ServiceUrlsController(DomainConfig domain) : ControllerBase
{ {
[HttpGet("services")] [HttpGet("services")]
public ActionResult<Dictionary<string, string>> Get() public ActionResult<Dictionary<string, string>> Get()
=> Ok(ConfigService.BuildServiceUrlMap(domain.Apex)); => Ok(ConfigService.BuildServiceUrlMap(domain));
} }
@@ -37,13 +37,13 @@ public class NsController : ControllerBase
// code: locker)" in BootSequence. // code: locker)" in BootSequence.
[HttpGet("/", Order = -1000)] [HttpGet("/", Order = -1000)]
public ActionResult<Dictionary<string, string>> Root() => public ActionResult<Dictionary<string, string>> Root() =>
Ok(ConfigService.BuildServiceUrlMap(_domain.Apex)); Ok(ConfigService.BuildServiceUrlMap(_domain));
// Some builds fetch /v1 or /v1/services directly // Some builds fetch /v1 or /v1/services directly
[HttpGet("/v1")] [HttpGet("/v1")]
[HttpGet("/v1/services")] [HttpGet("/v1/services")]
public ActionResult<Dictionary<string, string>> Services() => public ActionResult<Dictionary<string, string>> Services() =>
Ok(ConfigService.BuildServiceUrlMap(_domain.Apex)); Ok(ConfigService.BuildServiceUrlMap(_domain));
// Ping / health used by the client keep-alive loop // Ping / health used by the client keep-alive loop
[HttpGet("/v1/ping")] [HttpGet("/v1/ping")]
+60 -19
View File
@@ -87,14 +87,21 @@ var apex =
builder.Configuration["Domain:Apex"] builder.Configuration["Domain:Apex"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN") ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
?? "localhost"; ?? "localhost";
builder.Services.AddSingleton(new DomainConfig(apex)); var domainScheme =
builder.Configuration["Domain:Scheme"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_SCHEME")
?? "https";
var domainPort =
builder.Configuration["Domain:Port"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_PORT");
builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort));
builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt => builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
{ {
opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" }; opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" };
opt.AllowEmptyHosts = true; opt.AllowEmptyHosts = true;
opt.IncludeFailureMessage = true; opt.IncludeFailureMessage = true;
}); });
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]"); Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, allowedHosts=[{apex}, *.{apex}, localhost]");
// ── Services ────────────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────────────
builder.Services.AddScoped<PlayerService>(); builder.Services.AddScoped<PlayerService>();
@@ -441,28 +448,18 @@ app.Use(async (ctx, next) =>
} }
}); });
// ── Migrate DB ──────────────────────────────────────────────────────────────── // ── Create/repair DB ──────────────────────────────────────────────────────────
// EF Core migrations under Migrations/. On boot we apply any pending ones — // Local launcher installs use SQLite as an appliance database. For that path,
// `Migrate()` is a no-op when the DB is already up to date and applies the // create the current EF model directly instead of replaying the migration chain;
// missing migrations in order otherwise. Schema evolution is now exclusively // then run small idempotent repair patches for DBs made by older launcher builds.
// done via `dotnet ef migrations add <Name>`; never call `EnsureCreated` (it // Hosted Postgres keeps its existing EnsureCreated + explicit patch path below.
// bypasses the migration history table and leaves the db in a broken
// "exists but no __EFMigrationsHistory entries" state that future Migrate
// calls can't reason about).
//
// One-time upgrade path: DBs created by the previous EnsureCreated boot
// path already contain the Initial schema but have no __EFMigrationsHistory
// entries. The baseline detector below catches that case and inserts a
// row marking Initial as already applied, so Migrate() will only apply the
// Phase1+Phase2 deltas (column adds) instead of trying to re-CREATE TABLE.
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>(); var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (db.Database.IsSqlite()) if (db.Database.IsSqlite())
{ {
// Sqlite-only legacy baseline path — sqlite_master probes inside. db.Database.EnsureCreated();
BaselineExistingSchemaIfNeeded(db); ApplySqliteCompatibilityPatches(db);
db.Database.Migrate();
} }
else else
{ {
@@ -1185,3 +1182,47 @@ static void BaselineExistingSchemaIfNeeded(DorkNetDbContext db)
Log.Information( Log.Information(
"[migrations] Detected legacy EnsureCreated DB; baselined as 20260508092637_Initial. Subsequent migrations will apply normally."); "[migrations] Detected legacy EnsureCreated DB; baselined as 20260508092637_Initial. Subsequent migrations will apply normally.");
} }
static void ApplySqliteCompatibilityPatches(DorkNetDbContext db)
{
if (!db.Database.IsSqlite()) return;
AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse",
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
}
static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition)
{
var conn = db.Database.GetDbConnection();
var shouldClose = conn.State == System.Data.ConnectionState.Closed;
if (shouldClose) conn.Open();
try
{
var exists = false;
using var check = conn.CreateCommand();
check.CommandText = $"PRAGMA table_info(\"{table.Replace("\"", "\"\"")}\");";
{
using var reader = check.ExecuteReader();
while (reader.Read())
{
if (!string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) continue;
exists = true;
break;
}
}
if (exists)
{
Log.Information("[sqlite-compat] {Table}.{Column} already exists", table, column);
return;
}
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE \"{table.Replace("\"", "\"\"")}\" ADD COLUMN {definition};";
alter.ExecuteNonQuery();
Log.Information("[sqlite-compat] Added {Table}.{Column}", table, column);
}
finally
{
if (shouldClose) conn.Close();
}
}
+13 -4
View File
@@ -20,14 +20,12 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
// the client used to hit us. The baseUrl parameter is kept // the client used to hit us. The baseUrl parameter is kept
// for API stability but is no longer dereferenced. // for API stability but is no longer dereferenced.
_ = baseUrl; _ = baseUrl;
var apex = domain.Apex;
return new RecRoomConfig return new RecRoomConfig
{ {
MessageOfTheDay = config["Server:MOTD"] ?? "Welcome to the private server!", MessageOfTheDay = config["Server:MOTD"] ?? "Welcome to the private server!",
CdnBaseUri = $"https://{domain.Sub("cdn")}", CdnBaseUri = domain.SubUrl("cdn"),
PhotonConfig = photon, PhotonConfig = photon,
ServiceUrls = BuildServiceUrlMap(apex), ServiceUrls = BuildServiceUrlMap(domain),
ConfigTable = new Dictionary<string, string> ConfigTable = new Dictionary<string, string>
{ {
// Season keys the 2019/2020 client checks at startup // Season keys the 2019/2020 client checks at startup
@@ -106,4 +104,15 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
} }
return map; return map;
} }
public static Dictionary<string, string> BuildServiceUrlMap(DomainConfig domain)
{
var map = new Dictionary<string, string>(ServiceSubdomains.Length);
foreach (var (service, sub) in ServiceSubdomains)
{
var host = string.IsNullOrEmpty(sub) ? domain.Apex : domain.Sub(sub);
map[service] = domain.Url(host);
}
return map;
}
} }
+12 -1
View File
@@ -11,8 +11,19 @@ namespace DorkNet.Server.Services;
public sealed class DomainConfig public sealed class DomainConfig
{ {
public string Apex { get; } public string Apex { get; }
public DomainConfig(string apex) { Apex = apex; } public string Scheme { get; }
public string? Port { get; }
public DomainConfig(string apex, string scheme = "https", string? port = null)
{
Apex = apex;
Scheme = string.IsNullOrWhiteSpace(scheme) ? "https" : scheme.TrimEnd(':', '/', '\\');
Port = string.IsNullOrWhiteSpace(port) ? null : port.Trim().TrimStart(':');
}
public string Sub(string prefix) => $"{prefix}.{Apex}"; public string Sub(string prefix) => $"{prefix}.{Apex}";
public string Url(string host) => Port is null ? $"{Scheme}://{host}" : $"{Scheme}://{host}:{Port}";
public string SubUrl(string prefix) => Url(Sub(prefix));
public static bool MatchesSubdomain(string host, string prefix) public static bool MatchesSubdomain(string host, string prefix)
=> host.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase); => host.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase);
} }
+20 -7
View File
@@ -923,14 +923,27 @@ public class RoomService(DorkNetDbContext db)
// Name="BloodMoon" because SaveChanges hadn't fired yet, so // Name="BloodMoon" because SaveChanges hadn't fired yet, so
// the image binding silently no-op'd. Mutating the already- // the image binding silently no-op'd. Mutating the already-
// loaded entity sidesteps that race. // loaded entity sidesteps that race.
var bloodmoon = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "BloodMoon" || r.Name == "Crescendo"); var crescendoRooms = await db.Rooms
if (bloodmoon is not null) .Where(r => r.Name.ToLower() == "bloodmoon" || r.Name.ToLower() == "crescendo")
.ToListAsync();
var crescendo = crescendoRooms.FirstOrDefault(r =>
string.Equals(r.Name, "Crescendo", StringComparison.OrdinalIgnoreCase));
var bloodMoon = crescendoRooms.FirstOrDefault(r =>
string.Equals(r.Name, "BloodMoon", StringComparison.OrdinalIgnoreCase));
var shouldRenameBloodMoon = crescendo is null;
crescendo ??= bloodMoon;
if (crescendo is not null)
{ {
if (bloodmoon.Name != "Crescendo") bloodmoon.Name = "Crescendo"; if (shouldRenameBloodMoon && crescendo.Name != "Crescendo")
if (string.IsNullOrWhiteSpace(bloodmoon.Description)) crescendo.Name = "Crescendo";
bloodmoon.Description = "Brave the haunted halls of Castle Dracula and survive the night."; if (string.IsNullOrWhiteSpace(crescendo.Description))
if (bloodmoon.ImageName != "image_Crescendo.png") crescendo.Description = "Brave the haunted halls of Castle Dracula and survive the night.";
bloodmoon.ImageName = "image_Crescendo.png"; if (crescendo.ImageName != "image_Crescendo.png")
crescendo.ImageName = "image_Crescendo.png";
}
foreach (var duplicate in crescendoRooms.Where(r => r.Id != crescendo?.Id))
{
duplicate.HiddenFromBrowse = true;
} }
var paintball = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "Paintball"); var paintball = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "Paintball");
+1
View File
@@ -120,6 +120,7 @@ try {
'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json' 'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json'
'old_plugin_paths' = @( 'old_plugin_paths' = @(
'BepInEx/plugins/DorkNet.ClientPatch.dll' 'BepInEx/plugins/DorkNet.ClientPatch.dll'
'MelonLoader/Mods/DorkNet.ClientMod.dll'
) )
'steam_stub' = @{ 'steam_stub' = @{
'api_dll' = 'steam_api64.dll' 'api_dll' = 'steam_api64.dll'