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',