Compare commits

..

1 Commits

Author SHA1 Message Date
Alexa 7219ac4f35 ci(release): grant contents:write to the release workflow
The workflow creates a GitHub release and uploads assets, which needs the contents:write token scope. Declaring it per-workflow (matching release-launcher.yml on main) lets the repo keep a read-only default GITHUB_TOKEN.
2026-06-16 11:56:11 +03:00
17 changed files with 88 additions and 376 deletions
-2
View File
@@ -53,14 +53,12 @@ WORKDIR /src
# DorkNet.Models project ref, so its csproj needs to be present too. # DorkNet.Models project ref, so its csproj needs to be present too.
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/ COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/ COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
COPY DorkNet.Contracts/DorkNet.Contracts.csproj DorkNet.Contracts/
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
# Now copy the rest of the .NET source, then drop the prebuilt admin # 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. # SPA into wwwroot/admin so the publish step ships it as static content.
COPY DorkNet.Server/ DorkNet.Server/ COPY DorkNet.Server/ DorkNet.Server/
COPY DorkNet.Models/ DorkNet.Models/ COPY DorkNet.Models/ DorkNet.Models/
COPY DorkNet.Contracts/ DorkNet.Contracts/
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/ COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/ COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
+29 -57
View File
@@ -610,7 +610,17 @@ public class Mod : MelonMod
{ {
try try
{ {
var userData = ResolveUserDataDirectory(); // 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 <game>/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");
Directory.CreateDirectory(userData); Directory.CreateDirectory(userData);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log"); DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
var path = Path.Combine(userData, "dorknet-clientmod.json"); var path = Path.Combine(userData, "dorknet-clientmod.json");
@@ -621,21 +631,21 @@ public class Mod : MelonMod
} }
var doc = JsonDocument.Parse(File.ReadAllText(path)); var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement; var r = doc.RootElement;
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost; if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
if (TryGetConfigValue(r, "PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId; if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
if (TryGetConfigValue(r, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId; if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
if (TryGetConfigValue(r, "PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion; if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); if (r.TryGetProperty("TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey; if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble(); if (r.TryGetProperty("DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean(); if (r.TryGetProperty("DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32(); if (r.TryGetProperty("DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32(); if (r.TryGetProperty("DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean(); if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32(); if (r.TryGetProperty("QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array) if (r.TryGetProperty("QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{ {
var rooms = new List<string>(); var rooms = new List<string>();
foreach (var e in v.EnumerateArray()) foreach (var e in v.EnumerateArray())
@@ -643,8 +653,8 @@ public class Mod : MelonMod
rooms.Add(e.GetString()!); rooms.Add(e.GetString()!);
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none) Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
} }
if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean(); if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array) if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{ {
var list = new List<string>(); var list = new List<string>();
foreach (var e in v.EnumerateArray()) foreach (var e in v.EnumerateArray())
@@ -656,7 +666,7 @@ public class Mod : MelonMod
} }
// "InjectAuthValues" key in the template is now ignored — // "InjectAuthValues" key in the template is now ignored —
// see attic/AuthValuesInjector.cs.attic. // see attic/AuthValuesInjector.cs.attic.
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " + Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}"); $"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
} }
catch (Exception ex) catch (Exception ex)
@@ -665,44 +675,6 @@ 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 ──────────────────────────────────────── // ── Patch dispatch helpers ────────────────────────────────────────
private bool TryPatch(string label, Type target, string methodName, private bool TryPatch(string label, Type target, string methodName,
Type[]? args, string? prefix = null, string? postfix = null, Type[]? args, string? prefix = null, string? postfix = null,
@@ -164,20 +164,6 @@ public static class DorkNetRouteOwnership
public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups; public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups;
public static IReadOnlyList<string> PublicSubdomains =>
ServiceRouteGroups
.SelectMany(group => group.HostSubdomains)
// api.* is a shared public host: it owns no service by host and is
// dispatched by path (see ResolvePublicService's path-prefix pass),
// so it never appears in a group's HostSubdomains. It must still be
// whitelisted here or HostFiltering rejects every api.{apex} request.
.Append("api")
.Append(AdminHost)
.Append("feed")
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
public static bool IsInfrastructurePath(string path) public static bool IsInfrastructurePath(string path)
{ {
return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase) return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase)
-41
View File
@@ -26,20 +26,6 @@ var app = builder.Build();
app.MapDorkNetServiceDefaults(); 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<DorkNetServiceMapOptions> options) => app.MapGet("/internal/services", (IOptions<DorkNetServiceMapOptions> options) =>
Results.Ok(options.Value.Endpoints())); Results.Ok(options.Value.Endpoints()));
app.MapGet("/internal/routes", () => app.MapGet("/internal/routes", () =>
@@ -204,33 +190,6 @@ static string[] WebBrowserHosts(string apexDomain)
return [apexDomain, SubdomainHost("www", apexDomain)]; return [apexDomain, SubdomainHost("www", apexDomain)];
} }
static HashSet<string> BuildPublicHosts(string apexDomain)
{
var hosts = new HashSet<string>(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<string> publicHosts)
{
if (string.IsNullOrWhiteSpace(host))
{
return true;
}
return publicHosts.Contains(host.Split(':', 2)[0].Trim());
}
static void AddHostRoute( static void AddHostRoute(
List<RouteConfig> routes, List<RouteConfig> routes,
ref int order, ref int order,
@@ -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)); => Ok(ConfigService.BuildServiceUrlMap(domain.Apex));
} }
@@ -16,7 +16,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
{ {
error = "use_signalr_hub", error = "use_signalr_hub",
hint = $"connect to {domain.Url("notify", "/hub/v1")} instead", hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
}); });
/// <summary>GET <c>api/notification/hub/v1</c> — returns the /// <summary>GET <c>api/notification/hub/v1</c> — returns the
@@ -27,7 +27,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
[HttpGet("/api/notification/hub/v1")] [HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new public IActionResult HubInfo() => Ok(new
{ {
Url = domain.Url("notify", "/hub/v1"), Url = $"https://{domain.Sub("notify")}/hub/v1",
Protocol = "signalr", Protocol = "signalr",
Version = 1, Version = 1,
}); });
@@ -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)); Ok(ConfigService.BuildServiceUrlMap(_domain.Apex));
// 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)); Ok(ConfigService.BuildServiceUrlMap(_domain.Apex));
// Ping / health used by the client keep-alive loop // Ping / health used by the client keep-alive loop
[HttpGet("/v1/ping")] [HttpGet("/v1/ping")]
+7 -30
View File
@@ -180,13 +180,8 @@ public static class LegacyUpgrades
// either no-op (Crescendo now exists) or the only writer. // either no-op (Crescendo now exists) or the only writer.
for (var attempt = 1; attempt <= 2; attempt++) for (var attempt = 1; attempt <= 2; attempt++)
{ {
var renameCandidates = await db.Rooms var crescendoExists = await db.Rooms.AnyAsync(r => r.Name == "Crescendo", ct);
.Where(r => r.Name.ToLower() == "bloodmoon" || r.Name.ToLower() == "crescendo") var bloodMoon = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "BloodMoon", ct);
.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) if (bloodMoon is null && crescendoExists)
{ {
@@ -222,15 +217,15 @@ public static class LegacyUpgrades
{ {
// Both exist — the rename ran on a different row at // Both exist — the rename ran on a different row at
// some point, and the original BloodMoon row is now // some point, and the original BloodMoon row is now
// a stale duplicate. Park it under a unique legacy // a stale duplicate. Park it under CrescendoLegacy
// name and hide from browse so /goto/name/... still // and hide from browse so /goto/name/... still
// resolves Crescendo to the right row. // resolves Crescendo to the right row.
bloodMoon.Name = await PickUniqueRoomNameAsync(db, "CrescendoLegacy", bloodMoon.Id, ct); bloodMoon.Name = "CrescendoLegacy";
bloodMoon.HiddenFromBrowse = true; bloodMoon.HiddenFromBrowse = true;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
logger.LogInformation( logger.LogInformation(
"[legacy-upgrade] RenameBloodMoonToCrescendo: applied — Rooms.Id={Id} parked as {Name} (Crescendo already existed on a different row)", "[legacy-upgrade] RenameBloodMoonToCrescendo: applied — Rooms.Id={Id} parked as CrescendoLegacy (Crescendo already existed on a different row)",
bloodMoon.Id, bloodMoon.Name); bloodMoon.Id);
return; return;
} }
} }
@@ -246,22 +241,4 @@ public static class LegacyUpgrades
} }
} }
} }
private static async Task<string> 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}";
}
} }
-1
View File
@@ -73,7 +73,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DorkNet.Models\DorkNet.Models.csproj" /> <ProjectReference Include="..\DorkNet.Models\DorkNet.Models.csproj" />
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
</ItemGroup> </ItemGroup>
<!-- Service-slice containers reference DorkNet.Server for shared controllers <!-- Service-slice containers reference DorkNet.Server for shared controllers
+4 -6
View File
@@ -25,9 +25,9 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
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 = domain.Url("cdn"), CdnBaseUri = $"https://{domain.Sub("cdn")}",
PhotonConfig = photon, PhotonConfig = photon,
ServiceUrls = BuildServiceUrlMap(domain), ServiceUrls = BuildServiceUrlMap(apex),
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
@@ -110,14 +110,12 @@ public class ConfigService(IConfiguration config, DomainConfig domain)
/// <see cref="DomainConfig.Apex"/>) so the map stays a single /// <see cref="DomainConfig.Apex"/>) so the map stays a single
/// source of truth.</summary> /// source of truth.</summary>
public static Dictionary<string, string> BuildServiceUrlMap(string apex) public static Dictionary<string, string> BuildServiceUrlMap(string apex)
=> BuildServiceUrlMap(new DomainConfig(apex));
public static Dictionary<string, string> BuildServiceUrlMap(DomainConfig domain)
{ {
var map = new Dictionary<string, string>(ServiceSubdomains.Length); var map = new Dictionary<string, string>(ServiceSubdomains.Length);
foreach (var (service, sub) in ServiceSubdomains) foreach (var (service, sub) in ServiceSubdomains)
{ {
map[service] = domain.Url(sub); var host = string.IsNullOrEmpty(sub) ? apex : $"{sub}.{apex}";
map[service] = $"https://{host}";
} }
return map; return map;
} }
+1 -21
View File
@@ -11,28 +11,8 @@ namespace DorkNet.Server.Services;
public sealed class DomainConfig public sealed class DomainConfig
{ {
public string Apex { get; } public string Apex { get; }
public string Scheme { get; } public DomainConfig(string apex) { Apex = apex; }
public DomainConfig(string apex, string scheme = "https")
{
Apex = apex;
Scheme = NormalizeScheme(scheme);
}
public string Sub(string prefix) => $"{prefix}.{Apex}"; 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) public static bool MatchesSubdomain(string host, string prefix)
=> host.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase); => 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";
}
} }
@@ -70,7 +70,7 @@ public static class MiddlewarePipelineExtensions
if (ctx.Request.Host.Host.Equals(domainCfg.Sub("api"), StringComparison.OrdinalIgnoreCase) && if (ctx.Request.Host.Host.Equals(domainCfg.Sub("api"), StringComparison.OrdinalIgnoreCase) &&
(ctx.Request.Path == "/admin" || ctx.Request.Path.StartsWithSegments("/admin"))) (ctx.Request.Path == "/admin" || ctx.Request.Path.StartsWithSegments("/admin")))
{ {
ctx.Response.Redirect(domainCfg.Url("admin", "/"), permanent: false); ctx.Response.Redirect($"https://{domainCfg.Sub("admin")}/", permanent: false);
return; return;
} }
await next(); await next();
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using DorkNet.Contracts;
using DorkNet.Server.Compat; using DorkNet.Server.Compat;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Services; using DorkNet.Server.Services;
@@ -107,19 +106,14 @@ public static class ServiceCollectionExtensions
builder.Configuration["Domain:Apex"] builder.Configuration["Domain:Apex"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN") ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
?? "localhost"; ?? "localhost";
var scheme = builder.Configuration["Domain:Scheme"] ?? "https"; builder.Services.AddSingleton(new DomainConfig(apex));
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<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt => builder.Services.Configure<Microsoft.AspNetCore.HostFiltering.HostFilteringOptions>(opt =>
{ {
opt.AllowedHosts = allowedHosts; 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=[{string.Join(", ", allowedHosts)}]"); Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]");
} }
// ── Services ────────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────────
+1 -18
View File
@@ -17,9 +17,8 @@ x-dorknet-service-env: &dorknet-service-env
x-dorknet-app-networks: &dorknet-app-networks x-dorknet-app-networks: &dorknet-app-networks
networks: networks:
# Internal only — backends are reached by the gateway over `default`,
# never directly by Traefik, so keep them off dokploy-network.
- default - default
- dokploy-network
services: services:
gateway: gateway:
@@ -43,18 +42,6 @@ services:
DorkNet__Services__Monolith: http://monolith:8080 DorkNet__Services__Monolith: http://monolith:8080
expose: expose:
- "8080" - "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: depends_on:
- identity - identity
- rooms - rooms
@@ -70,10 +57,6 @@ services:
cloudflared: cloudflared:
image: cloudflare/cloudflared:latest image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN:?Set CLOUDFLARE_TUNNEL_TOKEN in Dokploy} 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: depends_on:
- gateway - gateway
restart: unless-stopped restart: unless-stopped
+13 -82
View File
@@ -1,7 +1,7 @@
# DorkNet Cloudflare Tunnel config — Coolify production target. # DorkNet Cloudflare Tunnel config — Coolify production target.
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Routes only DorkNet's known service subdomains through the tunnel # Routes every localhost subdomain through the tunnel into the server
# into the server container running on Coolify. The tunnel runs as its own Coolify # container running on Coolify. The tunnel runs as its own Coolify
# service (or as a sidecar in the same project) and reaches the # service (or as a sidecar in the same project) and reaches the
# server over the internal Docker network. # server over the internal Docker network.
# #
@@ -49,93 +49,24 @@ originRequest:
keepAliveTimeout: 10m keepAliveTimeout: 10m
noHappyEyeballs: false noHappyEyeballs: false
# Ingress rules are evaluated top-down. Replace localhost below with # Ingress rules are evaluated top-down. The wildcard catches every
# your DORKNET_DOMAIN when installing this as a static cloudflared # subdomain we don't list explicitly, so when the 2020 Rec Room client
# config. Do not use a wildcard hostname: only these known client-facing # (post-domain-patch) hits a niche subdomain like roomieintegrations.
# hosts should reach the gateway. # localhost, the request still reaches the server.
ingress: ingress:
- hostname: localhost - hostname: localhost
service: {{UPSTREAM}} service: {{UPSTREAM}}
originRequest: originRequest:
httpHostHeader: localhost httpHostHeader: localhost
- hostname: admin.localhost - hostname: "*.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}} 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 # Catch-all rejects anything that didn't match (defensive — the
# tunnel only routes traffic for hostnames listed above). # tunnel only routes traffic for hostnames we've added DNS records
# for, so this is rarely reached).
- service: http_status:404 - service: http_status:404
+16 -85
View File
@@ -1,7 +1,7 @@
# DorkNet Cloudflare Tunnel config # DorkNet Cloudflare Tunnel config
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Routes only DorkNet's known localhost service subdomains through the # Routes every localhost subdomain through the tunnel into the local
# tunnel into the local server (HTTPS on port 443). Generated by tools/setup-cloudflared.ps1 # server (HTTPS on port 443). Generated by tools/setup-cloudflared.ps1
# — copy to %USERPROFILE%\.cloudflared\config.yml and replace the # — copy to %USERPROFILE%\.cloudflared\config.yml and replace the
# {{TUNNEL_ID}} / {{CREDENTIALS_FILE}} placeholders. # {{TUNNEL_ID}} / {{CREDENTIALS_FILE}} placeholders.
# #
@@ -14,96 +14,27 @@
tunnel: {{TUNNEL_ID}} tunnel: {{TUNNEL_ID}}
credentials-file: {{CREDENTIALS_FILE}} credentials-file: {{CREDENTIALS_FILE}}
originRequest: # Ingress rules are evaluated top-down. The wildcard catches every
noTLSVerify: true # subdomain we don't list explicitly, so when the 2020 Rec Room client
# (post-domain-patch) hits a niche subdomain like roomieintegrations.
# Ingress rules are evaluated top-down. Keep this list aligned with # localhost, the request still reaches the server.
# DorkNetRouteOwnership.PublicSubdomains and ConfigService.ServiceSubdomains.
# Do not use *.localhost here: only the subdomains the client actually
# needs should reach the gateway/server.
ingress: ingress:
- hostname: localhost - hostname: localhost
service: https://localhost:443 service: https://localhost:443
originRequest: originRequest:
noTLSVerify: true
httpHostHeader: localhost httpHostHeader: localhost
- hostname: admin.localhost - hostname: "*.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 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 # Catch-all rejects anything that didn't match (defensive — the
# tunnel only routes traffic for hostnames listed above). # tunnel only routes traffic for hostnames we've added DNS records
# for, so this is rarely reached).
- service: http_status:404 - service: http_status:404
+8 -4
View File
@@ -78,15 +78,19 @@ $Template = Join-Path $PSScriptRoot 'cloudflared-config.yml.template'
$CfDir = Join-Path $env:USERPROFILE '.cloudflared' $CfDir = Join-Path $env:USERPROFILE '.cloudflared'
$ConfigPath = Join-Path $CfDir 'config.yml' $ConfigPath = Join-Path $CfDir 'config.yml'
# Subdomains routed through the tunnel. Keep this list aligned with # Subdomains we want routed through the tunnel. The wildcard route in
# DorkNetRouteOwnership.PublicSubdomains and the cloudflared config # config.yml catches everything; this list drives the per-subdomain
# template. We intentionally create explicit DNS routes instead of a # DNS CNAMEs Cloudflare needs (no automatic wildcard CNAME via the
# wildcard so unrelated subdomains do not reach the gateway. # CLI — each one is created explicitly via `cloudflared tunnel route
# dns`).
$Subdomains = @( $Subdomains = @(
'@', # apex (localhost itself) '@', # apex (localhost itself)
'admin', 'api', 'accounts', 'auth', 'cdn', 'chat', 'commerce', 'admin', 'api', 'accounts', 'auth', 'cdn', 'chat', 'commerce',
'discovery', 'econ', 'feed', 'geo', 'img', 'match', 'notify', 'discovery', 'econ', 'feed', 'geo', 'img', 'match', 'notify',
'ns', 'playersettings', 'rooms', 'storage', 'strings', 'strings-cdn', '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', 'bugreporting', 'cards', 'clubs', 'cms', 'data', 'datacollection',
'gamelogs', 'leaderboard', 'link', 'lists', 'moderation', 'gamelogs', 'leaderboard', 'link', 'lists', 'moderation',
'platformnotifications', 'roomcomments', 'roomieintegrations', 'platformnotifications', 'roomcomments', 'roomieintegrations',