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
19 changed files with 101 additions and 413 deletions
-2
View File
@@ -53,14 +53,12 @@ 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/
+29 -57
View File
@@ -610,7 +610,17 @@ public class Mod : MelonMod
{
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);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
var path = Path.Combine(userData, "dorknet-clientmod.json");
@@ -621,21 +631,21 @@ public class Mod : MelonMod
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
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)
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)
{
var rooms = new List<string>();
foreach (var e in v.EnumerateArray())
@@ -643,8 +653,8 @@ public class Mod : MelonMod
rooms.Add(e.GetString()!);
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
}
if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{
var list = new List<string>();
foreach (var e in v.EnumerateArray())
@@ -656,7 +666,7 @@ public class Mod : MelonMod
}
// "InjectAuthValues" key in the template is now ignored —
// 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}");
}
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 ────────────────────────────────────────
private bool TryPatch(string label, Type target, string methodName,
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<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)
{
return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase)
-41
View File
@@ -26,20 +26,6 @@ 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<DorkNetServiceMapOptions> options) =>
Results.Ok(options.Value.Endpoints()));
app.MapGet("/internal/routes", () =>
@@ -204,33 +190,6 @@ static string[] WebBrowserHosts(string 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(
List<RouteConfig> routes,
ref int order,
@@ -15,5 +15,5 @@ public class ServiceUrlsController(DomainConfig domain) : ControllerBase
{
[HttpGet("services")]
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
{
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
@@ -27,7 +27,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
[HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new
{
Url = domain.Url("notify", "/hub/v1"),
Url = $"https://{domain.Sub("notify")}/hub/v1",
Protocol = "signalr",
Version = 1,
});
@@ -20,6 +20,7 @@ namespace DorkNet.Server.Controllers.Match;
[ApiController]
public class MatchPlayerController(
PlayerPresenceService presence,
NotificationService notifications,
RoomService rooms,
PrivateInstanceService privateInstances,
IConfiguration config,
@@ -36,7 +37,7 @@ public class MatchPlayerController(
/// </summary>
[HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public IActionResult PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
{
var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
@@ -45,15 +46,17 @@ public class MatchPlayerController(
if (replaced)
{
log.LogWarning(
"[player-login] concurrent session marker replaced for player={PlayerId}; stale SignalR connections will be evicted on hub connect",
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push",
playerId);
// Do not broadcast ModerationKick to the whole player group
// here. During reconnects the active watch can already have a
// SignalR connection in that group, and the 2020 client crashes
// hard if it receives a malformed/stale moderation payload.
// NotifyHub.OnConnectedAsync snapshots existing connection ids
// before adding the new connection and sends a targeted kick to
// those ids only.
// The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's
// boot sequence — verified in the live trace), so the
// ModerationKick push hits only the OLDER session's
// existing connections. They'll show the "you've been
// kicked" dialog and gracefully disconnect via the
// watch's LocalModerationKickSelf path.
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
}
}
return Ok(Array.Empty<object>());
@@ -37,13 +37,13 @@ public class NsController : ControllerBase
// code: locker)" in BootSequence.
[HttpGet("/", Order = -1000)]
public ActionResult<Dictionary<string, string>> Root() =>
Ok(ConfigService.BuildServiceUrlMap(_domain));
Ok(ConfigService.BuildServiceUrlMap(_domain.Apex));
// Some builds fetch /v1 or /v1/services directly
[HttpGet("/v1")]
[HttpGet("/v1/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
[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.
for (var attempt = 1; attempt <= 2; attempt++)
{
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));
var crescendoExists = await db.Rooms.AnyAsync(r => r.Name == "Crescendo", ct);
var bloodMoon = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "BloodMoon", ct);
if (bloodMoon is null && crescendoExists)
{
@@ -222,15 +217,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 a unique legacy
// name and hide from browse so /goto/name/... still
// a stale duplicate. Park it under CrescendoLegacy
// and hide from browse so /goto/name/... still
// resolves Crescendo to the right row.
bloodMoon.Name = await PickUniqueRoomNameAsync(db, "CrescendoLegacy", bloodMoon.Id, ct);
bloodMoon.Name = "CrescendoLegacy";
bloodMoon.HiddenFromBrowse = true;
await db.SaveChangesAsync(ct);
logger.LogInformation(
"[legacy-upgrade] RenameBloodMoonToCrescendo: applied — Rooms.Id={Id} parked as {Name} (Crescendo already existed on a different row)",
bloodMoon.Id, bloodMoon.Name);
"[legacy-upgrade] RenameBloodMoonToCrescendo: applied — Rooms.Id={Id} parked as CrescendoLegacy (Crescendo already existed on a different row)",
bloodMoon.Id);
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>
<ProjectReference Include="..\DorkNet.Models\DorkNet.Models.csproj" />
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
</ItemGroup>
<!-- 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
{
MessageOfTheDay = config["Server:MOTD"] ?? "Welcome to the private server!",
CdnBaseUri = domain.Url("cdn"),
CdnBaseUri = $"https://{domain.Sub("cdn")}",
PhotonConfig = photon,
ServiceUrls = BuildServiceUrlMap(domain),
ServiceUrls = BuildServiceUrlMap(apex),
ConfigTable = new Dictionary<string, string>
{
// 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
/// source of truth.</summary>
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);
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;
}
+1 -21
View File
@@ -11,28 +11,8 @@ namespace DorkNet.Server.Services;
public sealed class DomainConfig
{
public string Apex { get; }
public string Scheme { get; }
public DomainConfig(string apex, string scheme = "https")
{
Apex = apex;
Scheme = NormalizeScheme(scheme);
}
public DomainConfig(string apex) { Apex = 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)
=> 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";
}
}
+1 -28
View File
@@ -64,12 +64,7 @@ public class GameSessionService(DorkNetDbContext db)
// in, so we save once with a placeholder, then patch and save
// again. Two SaveChangesAsync calls is cheap at our QPS.
PhotonRoomName = "pending",
// Track the room's "Max Player Count in This Subroom" slider
// instead of a hardcoded 8, so matchmade joiners fill the room to
// the host's chosen limit rather than fragmenting into a new
// instance every 8 players. (Existing sessions keep whatever cap
// they were created with.)
MaxCapacity = await ResolveRoomMaxCapacityAsync(effectiveRoom),
MaxCapacity = 8,
PlayerCount = 1,
CreatedAt = DateTime.UtcNow,
};
@@ -80,28 +75,6 @@ public class GameSessionService(DorkNetDbContext db)
return ToDto(session);
}
// Default session cap when we can't resolve a room-specific value (system
// rooms like the dorm use a GUID roomId that doesn't map to a RoomEntity).
private const int DefaultMaxCapacity = 8;
/// <summary>Resolve a new session's capacity from the room's entry
/// subroom (lowest <c>OrderIndex</c>) <see cref="RoomSceneEntity.MaxPlayers"/>
/// — the value the in-game "Max Player Count in This Subroom" slider
/// writes. Matchmade sessions land in the entry subroom, so its cap is the
/// right ceiling. GameSession roomIds are the room's numeric Id as a
/// string for user rooms; non-numeric (dorm GUID, etc.) → default. Clamped
/// to a sane Photon ceiling.</summary>
private async Task<int> ResolveRoomMaxCapacityAsync(string roomId)
{
if (!long.TryParse(roomId, out var roomLongId)) return DefaultMaxCapacity;
var max = await db.RoomScenes
.Where(s => s.RoomId == roomLongId)
.OrderBy(s => s.OrderIndex)
.Select(s => (int?)s.MaxPlayers)
.FirstOrDefaultAsync();
return max is int v && v > 0 ? Math.Clamp(v, 1, 80) : DefaultMaxCapacity;
}
public async Task<GameSession?> GetByIdAsync(long id)
{
var s = await db.GameSessions.AsNoTracking()
@@ -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(domainCfg.Url("admin", "/"), permanent: false);
ctx.Response.Redirect($"https://{domainCfg.Sub("admin")}/", permanent: false);
return;
}
await next();
@@ -1,7 +1,6 @@
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;
@@ -107,19 +106,14 @@ public static class ServiceCollectionExtensions
builder.Configuration["Domain:Apex"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
?? "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.AddSingleton(new DomainConfig(apex));
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.IncludeFailureMessage = true;
});
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{string.Join(", ", allowedHosts)}]");
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]");
}
// ── Services ──────────────────────────────────────────────────────────────
+1 -18
View File
@@ -17,9 +17,8 @@ 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:
@@ -43,18 +42,6 @@ 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
@@ -70,10 +57,6 @@ 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
+13 -82
View File
@@ -1,7 +1,7 @@
# DorkNet Cloudflare Tunnel config — Coolify production target.
# ────────────────────────────────────────────────────────────────────
# Routes only DorkNet's known service subdomains through the tunnel
# into the server container running on Coolify. The tunnel runs as its own Coolify
# Routes every localhost subdomain 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,93 +49,24 @@ originRequest:
keepAliveTimeout: 10m
noHappyEyeballs: false
# 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 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:
- hostname: localhost
service: {{UPSTREAM}}
originRequest:
httpHostHeader: 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
- hostname: "*.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 listed above).
# tunnel only routes traffic for hostnames we've added DNS records
# for, so this is rarely reached).
- service: http_status:404
+16 -85
View File
@@ -1,7 +1,7 @@
# DorkNet Cloudflare Tunnel config
# ────────────────────────────────────────────────────────────────────
# 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
# Routes every localhost subdomain 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,96 +14,27 @@
tunnel: {{TUNNEL_ID}}
credentials-file: {{CREDENTIALS_FILE}}
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 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:
- hostname: localhost
service: https://localhost:443
originRequest:
noTLSVerify: true
httpHostHeader: 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
- hostname: "*.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 listed above).
# tunnel only routes traffic for hostnames we've added DNS records
# for, so this is rarely reached).
- 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'
$ConfigPath = Join-Path $CfDir 'config.yml'
# 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 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 = @(
'@', # 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',