Compare commits

..

4 Commits

Author SHA1 Message Date
Alexa 05099cce91 Whitelist api.{apex} as a shared public host
api.{apex} is the client's primary API host, served by path
dispatch across every service. It belonged to no route group's
HostSubdomains, so it was absent from PublicSubdomains and thus
from the monolith's HostFiltering AllowedHosts — every request to
api.{apex} was rejected with 400 "Bad Request - Invalid Hostname".

Append "api" alongside admin/feed so it is whitelisted while
remaining path-routed (it owns no service by host).
2026-06-27 23:59:06 +03:00
Alexa 97f6522497 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.
2026-06-25 12:43:52 +03:00
Alexa e3bbcbe4a4 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.
2026-06-25 12:05:45 +03:00
Alexa 12b1a7f1e2 Restrict gateway to configured public hosts 2026-06-23 00:27:21 +03:00
9 changed files with 254 additions and 40 deletions
+2
View File
@@ -53,12 +53,14 @@ 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/
@@ -164,6 +164,20 @@ 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,6 +26,20 @@ 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", () =>
@@ -190,6 +204,33 @@ 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,
+1
View File
@@ -73,6 +73,7 @@
<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
@@ -1,6 +1,7 @@
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;
@@ -108,13 +109,17 @@ public static class ServiceCollectionExtensions
?? "localhost"; ?? "localhost";
var scheme = builder.Configuration["Domain:Scheme"] ?? "https"; var scheme = builder.Configuration["Domain:Scheme"] ?? "https";
builder.Services.AddSingleton(new DomainConfig(apex, scheme)); 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 = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" }; opt.AllowedHosts = allowedHosts;
opt.AllowEmptyHosts = true; opt.AllowEmptyHosts = true;
opt.IncludeFailureMessage = true; opt.IncludeFailureMessage = true;
}); });
Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]"); Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{string.Join(", ", allowedHosts)}]");
} }
// ── Services ────────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────────
+18 -1
View File
@@ -17,8 +17,9 @@ 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:
@@ -42,6 +43,18 @@ 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
@@ -57,6 +70,10 @@ 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
+82 -13
View File
@@ -1,7 +1,7 @@
# DorkNet Cloudflare Tunnel config — Coolify production target. # DorkNet Cloudflare Tunnel config — Coolify production target.
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Routes every localhost subdomain through the tunnel into the server # Routes only DorkNet's known service subdomains through the tunnel
# container running on Coolify. The tunnel runs as its own Coolify # 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 # 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,24 +49,93 @@ originRequest:
keepAliveTimeout: 10m keepAliveTimeout: 10m
noHappyEyeballs: false noHappyEyeballs: false
# Ingress rules are evaluated top-down. The wildcard catches every # Ingress rules are evaluated top-down. Replace localhost below with
# subdomain we don't list explicitly, so when the 2020 Rec Room client # your DORKNET_DOMAIN when installing this as a static cloudflared
# (post-domain-patch) hits a niche subdomain like roomieintegrations. # config. Do not use a wildcard hostname: only these known client-facing
# localhost, the request still reaches the server. # hosts should reach the gateway.
ingress: ingress:
- hostname: localhost - hostname: localhost
service: {{UPSTREAM}} service: {{UPSTREAM}}
originRequest: originRequest:
httpHostHeader: localhost 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}} 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 we've added DNS records # tunnel only routes traffic for hostnames listed above).
# for, so this is rarely reached).
- service: http_status:404 - service: http_status:404
+85 -16
View File
@@ -1,7 +1,7 @@
# DorkNet Cloudflare Tunnel config # DorkNet Cloudflare Tunnel config
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Routes every localhost subdomain through the tunnel into the local # Routes only DorkNet's known localhost service subdomains through the
# server (HTTPS on port 443). Generated by tools/setup-cloudflared.ps1 # tunnel into the local 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,27 +14,96 @@
tunnel: {{TUNNEL_ID}} tunnel: {{TUNNEL_ID}}
credentials-file: {{CREDENTIALS_FILE}} credentials-file: {{CREDENTIALS_FILE}}
# Ingress rules are evaluated top-down. The wildcard catches every originRequest:
# subdomain we don't list explicitly, so when the 2020 Rec Room client noTLSVerify: true
# (post-domain-patch) hits a niche subdomain like roomieintegrations.
# localhost, the request still reaches the server. # 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: ingress:
- hostname: localhost - hostname: localhost
service: https://localhost:443 service: https://localhost:443
originRequest: originRequest:
noTLSVerify: true
httpHostHeader: localhost 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 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 we've added DNS records # tunnel only routes traffic for hostnames listed above).
# for, so this is rarely reached).
- service: http_status:404 - service: http_status:404
+4 -8
View File
@@ -78,19 +78,15 @@ $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 we want routed through the tunnel. The wildcard route in # Subdomains routed through the tunnel. Keep this list aligned with
# config.yml catches everything; this list drives the per-subdomain # DorkNetRouteOwnership.PublicSubdomains and the cloudflared config
# DNS CNAMEs Cloudflare needs (no automatic wildcard CNAME via the # template. We intentionally create explicit DNS routes instead of a
# CLI — each one is created explicitly via `cloudflared tunnel route # wildcard so unrelated subdomains do not reach the gateway.
# 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',