Compare commits

...

2 Commits

Author SHA1 Message Date
Alexa 52e1d4f61c 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.
2026-06-25 12:04:06 +03:00
Alexa 12b1a7f1e2 Restrict gateway to configured public hosts 2026-06-23 00:27:21 +03:00
7 changed files with 246 additions and 40 deletions
@@ -164,6 +164,15 @@ 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)
.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,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',