From 1794d4e7771eaf6166f1a1ef1fcad99634fa5285 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:05:44 +0300 Subject: [PATCH 1/6] Fix Crescendo SQLite upgrade collision Fixes #21 by making the December legacy room rename safe when SQLite already has a Crescendo row. --- DorkNet.Server/Data/LegacyUpgrades.cs | 37 ++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/DorkNet.Server/Data/LegacyUpgrades.cs b/DorkNet.Server/Data/LegacyUpgrades.cs index 55f0c17..be11fb0 100644 --- a/DorkNet.Server/Data/LegacyUpgrades.cs +++ b/DorkNet.Server/Data/LegacyUpgrades.cs @@ -180,8 +180,13 @@ public static class LegacyUpgrades // either no-op (Crescendo now exists) or the only writer. for (var attempt = 1; attempt <= 2; attempt++) { - var crescendoExists = await db.Rooms.AnyAsync(r => r.Name == "Crescendo", ct); - var bloodMoon = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "BloodMoon", ct); + var renameCandidates = await db.Rooms + .Where(r => r.Name.ToLower() == "bloodmoon" || r.Name.ToLower() == "crescendo") + .ToListAsync(ct); + var crescendoExists = renameCandidates.Any(r => + string.Equals(r.Name, "Crescendo", StringComparison.OrdinalIgnoreCase)); + var bloodMoon = renameCandidates.FirstOrDefault(r => + string.Equals(r.Name, "BloodMoon", StringComparison.OrdinalIgnoreCase)); if (bloodMoon is null && crescendoExists) { @@ -217,15 +222,15 @@ public static class LegacyUpgrades { // Both exist — the rename ran on a different row at // some point, and the original BloodMoon row is now - // a stale duplicate. Park it under CrescendoLegacy - // and hide from browse so /goto/name/... still + // a stale duplicate. Park it under a unique legacy + // name and hide from browse so /goto/name/... still // resolves Crescendo to the right row. - bloodMoon.Name = "CrescendoLegacy"; + bloodMoon.Name = await PickUniqueRoomNameAsync(db, "CrescendoLegacy", bloodMoon.Id, ct); bloodMoon.HiddenFromBrowse = true; await db.SaveChangesAsync(ct); logger.LogInformation( - "[legacy-upgrade] RenameBloodMoonToCrescendo: applied — Rooms.Id={Id} parked as CrescendoLegacy (Crescendo already existed on a different row)", - bloodMoon.Id); + "[legacy-upgrade] RenameBloodMoonToCrescendo: applied — Rooms.Id={Id} parked as {Name} (Crescendo already existed on a different row)", + bloodMoon.Id, bloodMoon.Name); return; } } @@ -241,4 +246,22 @@ public static class LegacyUpgrades } } } + + private static async Task PickUniqueRoomNameAsync( + DorkNetDbContext db, string preferred, long currentRoomId, CancellationToken ct) + { + var taken = await db.Rooms + .Where(r => r.Id != currentRoomId && r.Name.ToLower().StartsWith(preferred.ToLower())) + .Select(r => r.Name) + .ToListAsync(ct); + + if (!taken.Any(n => string.Equals(n, preferred, StringComparison.OrdinalIgnoreCase))) + return preferred; + + var suffix = 2; + while (taken.Any(n => string.Equals(n, $"{preferred}{suffix}", StringComparison.OrdinalIgnoreCase))) + suffix++; + + return $"{preferred}{suffix}"; + } } From fa7486915b95ba16fb603b0628d233437a101314 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:09:01 +0300 Subject: [PATCH 2/6] Fix notification URLs in HTTP deployments Fixes #19 by making the December server advertise notification and service URLs with the configured deployment scheme. --- .../API/Config/V1/ServiceUrlsController.cs | 2 +- .../Notification/V2/NotificationController.cs | 4 ++-- DorkNet.Server/Controllers/Ns/NsController.cs | 4 ++-- DorkNet.Server/Services/ConfigService.cs | 10 +++++---- DorkNet.Server/Services/DomainConfig.cs | 22 ++++++++++++++++++- .../Startup/MiddlewarePipelineExtensions.cs | 2 +- .../Startup/ServiceCollectionExtensions.cs | 3 ++- 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs b/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs index fe6b770..b43f0b6 100644 --- a/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs +++ b/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs @@ -15,5 +15,5 @@ public class ServiceUrlsController(DomainConfig domain) : ControllerBase { [HttpGet("services")] public ActionResult> Get() - => Ok(ConfigService.BuildServiceUrlMap(domain.Apex)); + => Ok(ConfigService.BuildServiceUrlMap(domain)); } diff --git a/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs b/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs index c605673..61f8b57 100644 --- a/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs +++ b/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs @@ -16,7 +16,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new { error = "use_signalr_hub", - hint = $"connect to {domain.Sub("notify")}/hub/v1 instead", + hint = $"connect to {domain.Url("notify", "/hub/v1")} instead", }); /// GET api/notification/hub/v1 — returns the @@ -27,7 +27,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase [HttpGet("/api/notification/hub/v1")] public IActionResult HubInfo() => Ok(new { - Url = $"https://{domain.Sub("notify")}/hub/v1", + Url = domain.Url("notify", "/hub/v1"), Protocol = "signalr", Version = 1, }); diff --git a/DorkNet.Server/Controllers/Ns/NsController.cs b/DorkNet.Server/Controllers/Ns/NsController.cs index 2712a54..32c250e 100644 --- a/DorkNet.Server/Controllers/Ns/NsController.cs +++ b/DorkNet.Server/Controllers/Ns/NsController.cs @@ -37,13 +37,13 @@ public class NsController : ControllerBase // code: locker)" in BootSequence. [HttpGet("/", Order = -1000)] public ActionResult> Root() => - Ok(ConfigService.BuildServiceUrlMap(_domain.Apex)); + Ok(ConfigService.BuildServiceUrlMap(_domain)); // Some builds fetch /v1 or /v1/services directly [HttpGet("/v1")] [HttpGet("/v1/services")] public ActionResult> Services() => - Ok(ConfigService.BuildServiceUrlMap(_domain.Apex)); + Ok(ConfigService.BuildServiceUrlMap(_domain)); // Ping / health used by the client keep-alive loop [HttpGet("/v1/ping")] diff --git a/DorkNet.Server/Services/ConfigService.cs b/DorkNet.Server/Services/ConfigService.cs index e4f71d2..f92d35c 100644 --- a/DorkNet.Server/Services/ConfigService.cs +++ b/DorkNet.Server/Services/ConfigService.cs @@ -25,9 +25,9 @@ public class ConfigService(IConfiguration config, DomainConfig domain) return new RecRoomConfig { MessageOfTheDay = config["Server:MOTD"] ?? "Welcome to the private server!", - CdnBaseUri = $"https://{domain.Sub("cdn")}", + CdnBaseUri = domain.Url("cdn"), PhotonConfig = photon, - ServiceUrls = BuildServiceUrlMap(apex), + ServiceUrls = BuildServiceUrlMap(domain), ConfigTable = new Dictionary { // Season keys the 2019/2020 client checks at startup @@ -110,12 +110,14 @@ public class ConfigService(IConfiguration config, DomainConfig domain) /// ) so the map stays a single /// source of truth. public static Dictionary BuildServiceUrlMap(string apex) + => BuildServiceUrlMap(new DomainConfig(apex)); + + public static Dictionary BuildServiceUrlMap(DomainConfig domain) { var map = new Dictionary(ServiceSubdomains.Length); foreach (var (service, sub) in ServiceSubdomains) { - var host = string.IsNullOrEmpty(sub) ? apex : $"{sub}.{apex}"; - map[service] = $"https://{host}"; + map[service] = domain.Url(sub); } return map; } diff --git a/DorkNet.Server/Services/DomainConfig.cs b/DorkNet.Server/Services/DomainConfig.cs index 6bff828..2b328b3 100644 --- a/DorkNet.Server/Services/DomainConfig.cs +++ b/DorkNet.Server/Services/DomainConfig.cs @@ -11,8 +11,28 @@ namespace DorkNet.Server.Services; public sealed class DomainConfig { public string Apex { get; } - public DomainConfig(string apex) { Apex = apex; } + public string Scheme { get; } + + public DomainConfig(string apex, string scheme = "https") + { + Apex = apex; + Scheme = NormalizeScheme(scheme); + } + public string Sub(string prefix) => $"{prefix}.{Apex}"; + public string Url(string prefix, string path = "") + { + var host = string.IsNullOrEmpty(prefix) ? Apex : Sub(prefix); + if (string.IsNullOrEmpty(path)) return $"{Scheme}://{host}"; + return $"{Scheme}://{host}{(path.StartsWith('/') ? path : "/" + path)}"; + } + public static bool MatchesSubdomain(string host, string prefix) => host.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase); + + private static string NormalizeScheme(string? value) + { + var scheme = string.IsNullOrWhiteSpace(value) ? "https" : value.Trim().TrimEnd(':', '/', '\\').ToLowerInvariant(); + return scheme is "http" or "https" ? scheme : "https"; + } } diff --git a/DorkNet.Server/Startup/MiddlewarePipelineExtensions.cs b/DorkNet.Server/Startup/MiddlewarePipelineExtensions.cs index ae90ca2..4313c8a 100644 --- a/DorkNet.Server/Startup/MiddlewarePipelineExtensions.cs +++ b/DorkNet.Server/Startup/MiddlewarePipelineExtensions.cs @@ -70,7 +70,7 @@ public static class MiddlewarePipelineExtensions if (ctx.Request.Host.Host.Equals(domainCfg.Sub("api"), StringComparison.OrdinalIgnoreCase) && (ctx.Request.Path == "/admin" || ctx.Request.Path.StartsWithSegments("/admin"))) { - ctx.Response.Redirect($"https://{domainCfg.Sub("admin")}/", permanent: false); + ctx.Response.Redirect(domainCfg.Url("admin", "/"), permanent: false); return; } await next(); diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index ad5692f..4162d59 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -106,7 +106,8 @@ public static class ServiceCollectionExtensions builder.Configuration["Domain:Apex"] ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN") ?? "localhost"; - builder.Services.AddSingleton(new DomainConfig(apex)); + var scheme = builder.Configuration["Domain:Scheme"] ?? "https"; + builder.Services.AddSingleton(new DomainConfig(apex, scheme)); builder.Services.Configure(opt => { opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" }; From 157cc457a9a19cbe1601729c41278e3972cfbc05 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:20:43 +0300 Subject: [PATCH 3/6] Harden client mod config loading Fixes #24 by making the December client mod read the MelonLoader UserData directory directly when available and by accepting config keys case-insensitively. --- DorkNet.ClientMod/Mod.cs | 86 ++++++++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 94dccbc..93c1ac5 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -610,17 +610,7 @@ public class Mod : MelonMod { try { - // Compute the UserData path ourselves rather than calling - // MelonLoader.MelonUtils.UserDataDirectory (which is renamed - // to MelonEnvironment.UserDataDirectory in newer 0.6.x builds - // and shifts again in 0.7.x). Anchoring off the mod DLL's - // location keeps this stable across versions: the DLL lives - // in /Mods/, so two dirname()'s reach the game root, - // then MelonLoader/UserData/ is right next to it. - var modDll = Assembly.GetExecutingAssembly().Location; - var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty; - var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty; - var userData = Path.Combine(gameDir, "MelonLoader", "UserData"); + var userData = ResolveUserDataDirectory(); Directory.CreateDirectory(userData); DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log"); var path = Path.Combine(userData, "dorknet-clientmod.json"); @@ -631,21 +621,21 @@ public class Mod : MelonMod } var doc = JsonDocument.Parse(File.ReadAllText(path)); var r = doc.RootElement; - if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost; - if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId; - if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId; - if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion; - if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); - if (r.TryGetProperty("TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); - if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); - if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey; - if (r.TryGetProperty("DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble(); - if (r.TryGetProperty("DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean(); - if (r.TryGetProperty("DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32(); - if (r.TryGetProperty("DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32(); - if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean(); - if (r.TryGetProperty("QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32(); - if (r.TryGetProperty("QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array) + if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost; + if (TryGetConfigValue(r, "PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId; + if (TryGetConfigValue(r, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId; + if (TryGetConfigValue(r, "PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion; + if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); + if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); + if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); + if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey; + if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble(); + if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean(); + if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32(); + if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32(); + if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean(); + if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32(); + if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array) { var rooms = new List(); foreach (var e in v.EnumerateArray()) @@ -653,8 +643,8 @@ public class Mod : MelonMod rooms.Add(e.GetString()!); Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none) } - if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean(); - if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array) + if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean(); + if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array) { var list = new List(); foreach (var e in v.EnumerateArray()) @@ -666,7 +656,7 @@ public class Mod : MelonMod } // "InjectAuthValues" key in the template is now ignored — // see attic/AuthValuesInjector.cs.attic. - Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "" : "")}, " + + Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "" : "")}, " + $"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}"); } catch (Exception ex) @@ -675,6 +665,44 @@ public class Mod : MelonMod } } + private static string ResolveUserDataDirectory() + { + foreach (var typeName in new[] + { + "MelonLoader.MelonEnvironment, MelonLoader", + "MelonLoader.MelonUtils, MelonLoader", + }) + { + var type = Type.GetType(typeName); + var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static); + if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value)) + return value; + } + + var modDll = Assembly.GetExecutingAssembly().Location; + var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty; + var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory; + return Path.Combine(gameDir, "MelonLoader", "UserData"); + } + + private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value) + { + if (root.ValueKind == JsonValueKind.Object) + { + foreach (var prop in root.EnumerateObject()) + { + if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = prop.Value; + return true; + } + } + } + + value = default; + return false; + } + // ── Patch dispatch helpers ──────────────────────────────────────── private bool TryPatch(string label, Type target, string methodName, Type[]? args, string? prefix = null, string? postfix = null, From 12b1a7f1e274011ce52ca26234477bbcf0709e64 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:27:21 +0300 Subject: [PATCH 4/6] Restrict gateway to configured public hosts --- DorkNet.Contracts/DorkNetRouteOwnership.cs | 9 ++ DorkNet.Gateway/Program.cs | 41 +++++++ .../Startup/ServiceCollectionExtensions.cs | 9 +- tools/cloudflared-config.coolify.yml.template | 95 +++++++++++++--- tools/cloudflared-config.yml.template | 101 +++++++++++++++--- tools/setup-cloudflared.ps1 | 12 +-- 6 files changed, 228 insertions(+), 39 deletions(-) diff --git a/DorkNet.Contracts/DorkNetRouteOwnership.cs b/DorkNet.Contracts/DorkNetRouteOwnership.cs index b23474b..9242884 100644 --- a/DorkNet.Contracts/DorkNetRouteOwnership.cs +++ b/DorkNet.Contracts/DorkNetRouteOwnership.cs @@ -164,6 +164,15 @@ public static class DorkNetRouteOwnership public static IReadOnlyList RouteGroups => ServiceRouteGroups; + public static IReadOnlyList PublicSubdomains => + ServiceRouteGroups + .SelectMany(group => group.HostSubdomains) + .Append(AdminHost) + .Append("feed") + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + public static bool IsInfrastructurePath(string path) { return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase) diff --git a/DorkNet.Gateway/Program.cs b/DorkNet.Gateway/Program.cs index d4b24f1..8344956 100644 --- a/DorkNet.Gateway/Program.cs +++ b/DorkNet.Gateway/Program.cs @@ -26,6 +26,20 @@ var app = builder.Build(); app.MapDorkNetServiceDefaults(); +var publicHosts = BuildPublicHosts(apexDomain); +app.Use(async (ctx, next) => +{ + if (DorkNetRouteOwnership.IsInfrastructurePath(ctx.Request.Path.Value ?? "/") + || IsAllowedPublicHost(ctx.Request.Host.Host, publicHosts)) + { + await next(); + return; + } + + ctx.Response.StatusCode = StatusCodes.Status404NotFound; + await ctx.Response.WriteAsync("Unknown DorkNet host."); +}); + app.MapGet("/internal/services", (IOptions options) => Results.Ok(options.Value.Endpoints())); app.MapGet("/internal/routes", () => @@ -190,6 +204,33 @@ static string[] WebBrowserHosts(string apexDomain) return [apexDomain, SubdomainHost("www", apexDomain)]; } +static HashSet BuildPublicHosts(string apexDomain) +{ + var hosts = new HashSet(StringComparer.OrdinalIgnoreCase) + { + apexDomain, + "localhost", + "127.0.0.1", + }; + + foreach (var subdomain in DorkNetRouteOwnership.PublicSubdomains) + { + hosts.Add(SubdomainHost(subdomain, apexDomain)); + } + + return hosts; +} + +static bool IsAllowedPublicHost(string? host, HashSet publicHosts) +{ + if (string.IsNullOrWhiteSpace(host)) + { + return true; + } + + return publicHosts.Contains(host.Split(':', 2)[0].Trim()); +} + static void AddHostRoute( List routes, ref int order, diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 4162d59..0331db7 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using DorkNet.Contracts; using DorkNet.Server.Compat; using DorkNet.Server.Data; using DorkNet.Server.Services; @@ -108,13 +109,17 @@ public static class ServiceCollectionExtensions ?? "localhost"; var scheme = builder.Configuration["Domain:Scheme"] ?? "https"; builder.Services.AddSingleton(new DomainConfig(apex, scheme)); + var allowedHosts = new[] { apex, "localhost", "127.0.0.1" } + .Concat(DorkNetRouteOwnership.PublicSubdomains.Select(subdomain => $"{subdomain}.{apex}")) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); builder.Services.Configure(opt => { - opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" }; + opt.AllowedHosts = allowedHosts; opt.AllowEmptyHosts = true; opt.IncludeFailureMessage = true; }); - Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]"); + Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{string.Join(", ", allowedHosts)}]"); } // ── Services ────────────────────────────────────────────────────────────── diff --git a/tools/cloudflared-config.coolify.yml.template b/tools/cloudflared-config.coolify.yml.template index f263b44..f838b98 100644 --- a/tools/cloudflared-config.coolify.yml.template +++ b/tools/cloudflared-config.coolify.yml.template @@ -1,7 +1,7 @@ # DorkNet Cloudflare Tunnel config — Coolify production target. # ──────────────────────────────────────────────────────────────────── -# Routes every localhost subdomain through the tunnel into the server -# container running on Coolify. The tunnel runs as its own Coolify +# Routes only DorkNet's known service subdomains through the tunnel +# into the server container running on Coolify. The tunnel runs as its own Coolify # service (or as a sidecar in the same project) and reaches the # server over the internal Docker network. # @@ -49,24 +49,93 @@ originRequest: keepAliveTimeout: 10m noHappyEyeballs: false -# Ingress rules are evaluated top-down. The wildcard catches every -# subdomain we don't list explicitly, so when the 2020 Rec Room client -# (post-domain-patch) hits a niche subdomain like roomieintegrations. -# localhost, the request still reaches the server. +# Ingress rules are evaluated top-down. Replace localhost below with +# your DORKNET_DOMAIN when installing this as a static cloudflared +# config. Do not use a wildcard hostname: only these known client-facing +# hosts should reach the gateway. ingress: - hostname: localhost service: {{UPSTREAM}} originRequest: httpHostHeader: localhost - - hostname: "*.localhost" + - hostname: admin.localhost + service: {{UPSTREAM}} + - hostname: api.localhost + service: {{UPSTREAM}} + - hostname: accounts.localhost + service: {{UPSTREAM}} + - hostname: auth.localhost + service: {{UPSTREAM}} + - hostname: bugreporting.localhost + service: {{UPSTREAM}} + - hostname: cards.localhost + service: {{UPSTREAM}} + - hostname: cdn.localhost + service: {{UPSTREAM}} + - hostname: chat.localhost + service: {{UPSTREAM}} + - hostname: clubs.localhost + service: {{UPSTREAM}} + - hostname: cms.localhost + service: {{UPSTREAM}} + - hostname: commerce.localhost + service: {{UPSTREAM}} + - hostname: data.localhost + service: {{UPSTREAM}} + - hostname: datacollection.localhost + service: {{UPSTREAM}} + - hostname: discovery.localhost + service: {{UPSTREAM}} + - hostname: econ.localhost + service: {{UPSTREAM}} + - hostname: feed.localhost + service: {{UPSTREAM}} + - hostname: gamelogs.localhost + service: {{UPSTREAM}} + - hostname: geo.localhost + service: {{UPSTREAM}} + - hostname: img.localhost + service: {{UPSTREAM}} + - hostname: leaderboard.localhost + service: {{UPSTREAM}} + - hostname: link.localhost + service: {{UPSTREAM}} + - hostname: lists.localhost + service: {{UPSTREAM}} + - hostname: match.localhost + service: {{UPSTREAM}} + - hostname: moderation.localhost + service: {{UPSTREAM}} + - hostname: ns.localhost + service: {{UPSTREAM}} + - hostname: notify.localhost + service: {{UPSTREAM}} + - hostname: platformnotifications.localhost + service: {{UPSTREAM}} + - hostname: playersettings.localhost + service: {{UPSTREAM}} + - hostname: roomcomments.localhost + service: {{UPSTREAM}} + - hostname: roomieintegrations.localhost + service: {{UPSTREAM}} + - hostname: rooms.localhost + service: {{UPSTREAM}} + - hostname: storage.localhost + service: {{UPSTREAM}} + - hostname: strings.localhost + service: {{UPSTREAM}} + - hostname: strings-cdn.localhost + service: {{UPSTREAM}} + - hostname: studio.localhost + service: {{UPSTREAM}} + - hostname: thorn.localhost + service: {{UPSTREAM}} + - hostname: videos.localhost + service: {{UPSTREAM}} + - hostname: www.localhost service: {{UPSTREAM}} - # cloudflared forwards the original Host header by default, which - # is what the server's [Host("api.localhost")] etc. attributes match - # against. No explicit httpHostHeader here so wildcards stay - # transparent. # Catch-all rejects anything that didn't match (defensive — the - # tunnel only routes traffic for hostnames we've added DNS records - # for, so this is rarely reached). + # tunnel only routes traffic for hostnames listed above). - service: http_status:404 diff --git a/tools/cloudflared-config.yml.template b/tools/cloudflared-config.yml.template index 43b853a..78b38b4 100644 --- a/tools/cloudflared-config.yml.template +++ b/tools/cloudflared-config.yml.template @@ -1,7 +1,7 @@ # DorkNet Cloudflare Tunnel config # ──────────────────────────────────────────────────────────────────── -# Routes every localhost subdomain through the tunnel into the local -# server (HTTPS on port 443). Generated by tools/setup-cloudflared.ps1 +# Routes only DorkNet's known localhost service subdomains through the +# tunnel into the local server (HTTPS on port 443). Generated by tools/setup-cloudflared.ps1 # — copy to %USERPROFILE%\.cloudflared\config.yml and replace the # {{TUNNEL_ID}} / {{CREDENTIALS_FILE}} placeholders. # @@ -14,27 +14,96 @@ tunnel: {{TUNNEL_ID}} credentials-file: {{CREDENTIALS_FILE}} -# Ingress rules are evaluated top-down. The wildcard catches every -# subdomain we don't list explicitly, so when the 2020 Rec Room client -# (post-domain-patch) hits a niche subdomain like roomieintegrations. -# localhost, the request still reaches the server. +originRequest: + noTLSVerify: true + +# Ingress rules are evaluated top-down. Keep this list aligned with +# DorkNetRouteOwnership.PublicSubdomains and ConfigService.ServiceSubdomains. +# Do not use *.localhost here: only the subdomains the client actually +# needs should reach the gateway/server. ingress: - hostname: localhost service: https://localhost:443 originRequest: - noTLSVerify: true httpHostHeader: localhost - - hostname: "*.localhost" + - hostname: admin.localhost + service: https://localhost:443 + - hostname: api.localhost + service: https://localhost:443 + - hostname: accounts.localhost + service: https://localhost:443 + - hostname: auth.localhost + service: https://localhost:443 + - hostname: bugreporting.localhost + service: https://localhost:443 + - hostname: cards.localhost + service: https://localhost:443 + - hostname: cdn.localhost + service: https://localhost:443 + - hostname: chat.localhost + service: https://localhost:443 + - hostname: clubs.localhost + service: https://localhost:443 + - hostname: cms.localhost + service: https://localhost:443 + - hostname: commerce.localhost + service: https://localhost:443 + - hostname: data.localhost + service: https://localhost:443 + - hostname: datacollection.localhost + service: https://localhost:443 + - hostname: discovery.localhost + service: https://localhost:443 + - hostname: econ.localhost + service: https://localhost:443 + - hostname: feed.localhost + service: https://localhost:443 + - hostname: gamelogs.localhost + service: https://localhost:443 + - hostname: geo.localhost + service: https://localhost:443 + - hostname: img.localhost + service: https://localhost:443 + - hostname: leaderboard.localhost + service: https://localhost:443 + - hostname: link.localhost + service: https://localhost:443 + - hostname: lists.localhost + service: https://localhost:443 + - hostname: match.localhost + service: https://localhost:443 + - hostname: moderation.localhost + service: https://localhost:443 + - hostname: ns.localhost + service: https://localhost:443 + - hostname: notify.localhost + service: https://localhost:443 + - hostname: platformnotifications.localhost + service: https://localhost:443 + - hostname: playersettings.localhost + service: https://localhost:443 + - hostname: roomcomments.localhost + service: https://localhost:443 + - hostname: roomieintegrations.localhost + service: https://localhost:443 + - hostname: rooms.localhost + service: https://localhost:443 + - hostname: storage.localhost + service: https://localhost:443 + - hostname: strings.localhost + service: https://localhost:443 + - hostname: strings-cdn.localhost + service: https://localhost:443 + - hostname: studio.localhost + service: https://localhost:443 + - hostname: thorn.localhost + service: https://localhost:443 + - hostname: videos.localhost + service: https://localhost:443 + - hostname: www.localhost service: https://localhost:443 - originRequest: - noTLSVerify: true - # Forward the original Host header verbatim so the server's - # [Host("api.localhost")] etc. attributes match correctly. - # (cloudflared does this by default; setting it explicitly so - # config.yml is self-documenting.) # Catch-all rejects anything that didn't match (defensive — the - # tunnel only routes traffic for hostnames we've added DNS records - # for, so this is rarely reached). + # tunnel only routes traffic for hostnames listed above). - service: http_status:404 diff --git a/tools/setup-cloudflared.ps1 b/tools/setup-cloudflared.ps1 index 7984c9c..c33d40f 100644 --- a/tools/setup-cloudflared.ps1 +++ b/tools/setup-cloudflared.ps1 @@ -78,19 +78,15 @@ $Template = Join-Path $PSScriptRoot 'cloudflared-config.yml.template' $CfDir = Join-Path $env:USERPROFILE '.cloudflared' $ConfigPath = Join-Path $CfDir 'config.yml' -# Subdomains we want routed through the tunnel. The wildcard route in -# config.yml catches everything; this list drives the per-subdomain -# DNS CNAMEs Cloudflare needs (no automatic wildcard CNAME via the -# CLI — each one is created explicitly via `cloudflared tunnel route -# dns`). +# Subdomains routed through the tunnel. Keep this list aligned with +# DorkNetRouteOwnership.PublicSubdomains and the cloudflared config +# template. We intentionally create explicit DNS routes instead of a +# wildcard so unrelated subdomains do not reach the gateway. $Subdomains = @( '@', # apex (localhost itself) 'admin', 'api', 'accounts', 'auth', 'cdn', 'chat', 'commerce', 'discovery', 'econ', 'feed', 'geo', 'img', 'match', 'notify', 'ns', 'playersettings', 'rooms', 'storage', 'strings', 'strings-cdn', - # Long-tail subdomains DorkNet's stub controllers ack — adding - # CNAMEs for these means the patched client never hits a NXDOMAIN - # for any of the URLs it might probe. 'bugreporting', 'cards', 'clubs', 'cms', 'data', 'datacollection', 'gamelogs', 'leaderboard', 'link', 'lists', 'moderation', 'platformnotifications', 'roomcomments', 'roomieintegrations', From e3bbcbe4a4fbf7a5cbb50fedcbb54069d89317b6 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:05:45 +0300 Subject: [PATCH 5/6] Route microservices through Traefik behind Cloudflare Tunnel (#32) Add scoped Traefik host labels to the gateway and put cloudflared on the Traefik network so the tunnel hands off to Traefik instead of forwarding every host straight to the app. Keep backend services internal-only. --- docker-compose.microservices.dokploy.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docker-compose.microservices.dokploy.yml b/docker-compose.microservices.dokploy.yml index 39cd1af..3e70692 100644 --- a/docker-compose.microservices.dokploy.yml +++ b/docker-compose.microservices.dokploy.yml @@ -17,8 +17,9 @@ x-dorknet-service-env: &dorknet-service-env x-dorknet-app-networks: &dorknet-app-networks networks: + # Internal only — backends are reached by the gateway over `default`, + # never directly by Traefik, so keep them off dokploy-network. - default - - dokploy-network services: gateway: @@ -42,6 +43,18 @@ services: DorkNet__Services__Monolith: http://monolith:8080 expose: - "8080" + networks: + - default + - dokploy-network + labels: + # Cloudflare terminates TLS at the edge; the tunnel -> Traefik leg is + # internal HTTP, so route on the `web` entrypoint with no certresolver. + - traefik.enable=true + - traefik.docker.network=dokploy-network + # Scope routing to YOUR domain (apex + subdomains) instead of catch-all: + - traefik.http.routers.dorknet-gw.rule=Host(`${DORKNET_DOMAIN}`) || HostRegexp(`^.+\.${DORKNET_DOMAIN}$`) + - traefik.http.routers.dorknet-gw.entrypoints=web + - traefik.http.services.dorknet-gw.loadbalancer.server.port=8080 depends_on: - identity - rooms @@ -57,6 +70,10 @@ services: cloudflared: image: cloudflare/cloudflared:latest command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN:?Set CLOUDFLARE_TUNNEL_TOKEN in Dokploy} + # Must share dokploy-network so the tunnel can reach the Traefik + # container (dokploy-traefik) by name and hand traffic off to it. + networks: + - dokploy-network depends_on: - gateway restart: unless-stopped From 97f65224972d0e7c1c9fd22420ad35e5f95f6f60 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:43:52 +0300 Subject: [PATCH 6/6] Add DorkNet.Contracts reference to monolith Server build (#33) * Route microservices through Traefik behind Cloudflare Tunnel Add scoped Traefik host labels to the gateway and put cloudflared on the Traefik network so the tunnel hands off to Traefik instead of forwarding every host straight to the app. Keep backend services internal-only. * Add DorkNet.Contracts reference to monolith Server build DorkNet.Server uses DorkNetRouteOwnership from DorkNet.Contracts but the project never referenced it and the Dockerfile never copied it, so the monolith image failed to compile (CS0234). Add the project reference and copy the project into the build stage. --- Dockerfile | 2 ++ DorkNet.Server/DorkNet.Server.csproj | 1 + 2 files changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 8c50073..33ff0b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,12 +53,14 @@ WORKDIR /src # DorkNet.Models project ref, so its csproj needs to be present too. COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/ COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/ +COPY DorkNet.Contracts/DorkNet.Contracts.csproj DorkNet.Contracts/ RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj # Now copy the rest of the .NET source, then drop the prebuilt admin # SPA into wwwroot/admin so the publish step ships it as static content. COPY DorkNet.Server/ DorkNet.Server/ COPY DorkNet.Models/ DorkNet.Models/ +COPY DorkNet.Contracts/ DorkNet.Contracts/ COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/ COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/ diff --git a/DorkNet.Server/DorkNet.Server.csproj b/DorkNet.Server/DorkNet.Server.csproj index f1b8c07..f37f54a 100644 --- a/DorkNet.Server/DorkNet.Server.csproj +++ b/DorkNet.Server/DorkNet.Server.csproj @@ -73,6 +73,7 @@ +