Compare commits

...

15 Commits

Author SHA1 Message Date
Alexa 399f49fa8a Migrate auth to Open.IdentityServer 2026-06-30 15:47:27 +03:00
Alexa c192d9c051 Handle signing certificate decode failures 2026-06-30 14:45:10 +03:00
Alexa c80b5c56eb Harden signing certificate base64 parsing 2026-06-30 14:34:48 +03:00
Alexa efad7e10d0 Use native game auth client only 2026-06-30 14:29:55 +03:00
Alexa 88740b1102 Support native game auth client 2026-06-30 14:16:34 +03:00
Alexa af1ec31fab Restore device password grant compatibility 2026-06-30 14:05:59 +03:00
Alexa 5f2fd2875f Support env-based signing certificate 2026-06-30 13:54:57 +03:00
Alexa 2e7b39c2cd Migrate server auth to IdentityServer 2026-06-30 13:27:55 +03:00
Alexa 3e4d618aa7 feat: Matchmade sessions respect room's subroom Max Player Count
GameSessionService previously hardcoded MaxCapacity=8 for new sessions,
fragmenting matchmade joiners 8-per-instance regardless of the host's
'Max Player Count in This Subroom' slider. New sessions now resolve the
room's entry subroom (lowest OrderIndex) RoomSceneEntity.MaxPlayers,
clamped 1-80, falling back to 8 for system rooms (dorm GUID roomIds).
2026-06-28 01:35:57 +03:00
Alexa 72c076535c Merge branch 'december-2020-12-18' of https://github.com/DorkSquadRR/DorkNet into december-2020-12-18 2026-06-28 01:30:40 +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
Alexa 02365f5baf Merge branch 'december-2020-12-18' of https://github.com/DorkSquadRR/DorkNet into december-2020-12-18 2026-06-20 00:18:31 +03:00
Alexa 3755593dbb Avoid login kick broadcast during reconnect 2026-06-15 00:20:05 +03:00
43 changed files with 1284 additions and 395 deletions
+6 -4
View File
@@ -1,14 +1,14 @@
# DorkNet.Server container image — Coolify deploy target. # DorkNet.Server container image — Coolify deploy target.
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the # Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:9.0 with only the published output. # slimmer aspnet:10.0 with only the published output.
# #
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible # Build context: repo root (so Tools/ and DorkNet.Models/ are visible
# to ProjectReference + dotnet restore). The .dockerignore at the # to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the # repo root keeps the bin/, obj/, and other dev junk out of the
# context. # context.
ARG DOTNET_VERSION=9.0 ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20 ARG NODE_VERSION=20
# ── admin SPA build stage ────────────────────────────────────────── # ── admin SPA build stage ──────────────────────────────────────────
@@ -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/
@@ -76,13 +78,13 @@ WORKDIR /app
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for # curl + wget for healthcheck probes — Coolify v4 defaults to wget for
# its own probe regardless of what the image's HEALTHCHECK uses, so # its own probe regardless of what the image's HEALTHCHECK uses, so
# both have to be present. The aspnet:9.0 base image strips both by # both have to be present. The aspnet:10.0 base image strips both by
# default to keep the surface area small. # default to keep the surface area small.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \ && apt-get install -y --no-install-recommends curl wget \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654. # Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID. # Coolify mounts persistent volumes with write permission for this UID.
USER app USER app
+1 -1
View File
@@ -1,4 +1,4 @@
ARG DOTNET_VERSION=9.0 ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20 ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS admin-ui FROM node:${NODE_VERSION}-alpine AS admin-ui
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -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)
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+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,
+2 -2
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" /> <PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -1,11 +1,8 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HostFiltering; using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace DorkNet.Server.Tests; namespace DorkNet.Server.Tests;
@@ -56,18 +53,6 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
options.AllowEmptyHosts = true; options.AllowEmptyHosts = true;
options.IncludeFailureMessage = true; options.IncludeFailureMessage = true;
}); });
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
});
}); });
} }
@@ -26,6 +26,8 @@ public static class GameClientSessionFactory
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string> tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{ {
["grant_type"] = "password", ["grant_type"] = "password",
["client_id"] = "recroom",
["client_secret"] = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2",
["username"] = "EndpointContract", ["username"] = "EndpointContract",
["device_id"] = deviceId, ["device_id"] = deviceId,
["platform"] = "0", ["platform"] = "0",
@@ -0,0 +1,590 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Open.IdentityServer;
using Open.IdentityServer.Models;
using Open.IdentityServer.Services;
using Open.IdentityServer.Validation;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Auth;
public sealed class IdentityServerSigningKeyProvider
{
private readonly string _legacySecret;
public IdentityServerSigningKeyProvider(IConfiguration config)
{
_legacySecret =
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
CertificatePath = ResolveCertificatePath(config);
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
if (!string.IsNullOrWhiteSpace(certificateBase64))
{
LoadedFromBase64 = true;
Certificate = LoadCertificateFromBase64(certificateBase64, password);
}
else
{
Certificate = LoadOrCreateCertificate(CertificatePath, password);
}
SigningKey = new X509SecurityKey(Certificate);
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
}
public string CertificatePath { get; }
public bool LoadedFromBase64 { get; }
public string CertificateSource => LoadedFromBase64
? "IdentityServer:SigningCertificateBase64"
: CertificatePath;
public X509Certificate2 Certificate { get; }
public SecurityKey SigningKey { get; }
public SecurityKey LegacyJwtKey { get; }
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
private static string ResolveCertificatePath(IConfiguration config)
{
var path = config["IdentityServer:SigningCertificatePath"];
if (string.IsNullOrWhiteSpace(path))
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
return path;
}
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
var compact = NormalizeCertificateBase64(certificateBase64);
byte[] bytes;
try
{
bytes = Convert.FromBase64String(compact);
}
catch (FormatException ex)
{
throw new InvalidOperationException(
"IdentityServer signing certificate base64 is invalid. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
"without a file path or shell assignment text.",
ex);
}
try
{
return new X509Certificate2(bytes, password, flags);
}
catch (CryptographicException ex)
{
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
return certificate;
throw new InvalidOperationException(
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
"IdentityServer__SigningCertificatePassword matches the PFX password.",
ex);
}
}
private static bool TryLoadPemCertificate(
byte[] bytes,
string password,
X509KeyStorageFlags flags,
out X509Certificate2 certificate)
{
certificate = null!;
string pem;
try
{
pem = Encoding.UTF8.GetString(bytes);
}
catch (DecoderFallbackException)
{
return false;
}
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
return false;
try
{
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
if (!pemCertificate.HasPrivateKey)
return false;
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
certificate = new X509Certificate2(pfxBytes, password, flags);
return true;
}
catch (CryptographicException)
{
return false;
}
}
private static string NormalizeCertificateBase64(string value)
{
var text = value.Trim().Trim('"', '\'');
var equals = text.IndexOf('=');
if (equals > 0)
{
var key = text[..equals].Trim();
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
text = text[(equals + 1)..].Trim().Trim('"', '\'');
}
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = text.IndexOf(',');
if (comma >= 0)
text = text[(comma + 1)..];
}
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
text = text["base64:".Length..];
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
.Replace('-', '+')
.Replace('_', '/');
return (compact.Length % 4) switch
{
2 => compact + "==",
3 => compact + "=",
_ => compact,
};
}
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path))
return new X509Certificate2(path, password, flags);
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=DorkNet IdentityServer Signing",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
using var created = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(10));
var bytes = created.Export(X509ContentType.Pfx, password);
File.WriteAllBytes(path, bytes);
return new X509Certificate2(bytes, password, flags);
}
}
public static class RecRoomIdentityServerRegistration
{
public static void AddRecRoomIdentityServer(
this IServiceCollection services,
IConfiguration config,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeyProvider)
{
services
.AddIdentityServer(options =>
{
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
})
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
.AddProfileService<RecRoomProfileService>();
}
}
internal static class RecRoomIdentityServerConfig
{
public const string ClientId = "recroom";
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
public const string GameClientScope = "gameClient";
public const string CachedLoginGrantType = "cached_login";
public static readonly IdentityResource[] IdentityResources =
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
};
public static readonly ApiScope[] ApiScopes =
{
new(GameClientScope, "Game client")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
new("api", "DorkNet API")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
};
public static readonly Client[] Clients =
{
new()
{
ClientId = ClientId,
ClientName = "Rec Room game client",
RequireClientSecret = true,
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
AllowedScopes = RecRoomScopes(),
AllowOfflineAccess = true,
AccessTokenLifetime = 60 * 60 * 12,
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
RefreshTokenUsage = TokenUsage.ReUse,
RefreshTokenExpiration = TokenExpiration.Absolute,
UpdateAccessTokenClaimsOnRefresh = true,
AlwaysIncludeUserClaimsInIdToken = true,
},
};
private static ICollection<string> RecRoomScopes() =>
[
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
GameClientScope,
"api",
];
}
public sealed class IdentityServerGameTokenRequestMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsTokenRequest(context))
{
await next(context);
return;
}
var form = await context.Request.ReadFormAsync();
var values = form.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value,
StringComparer.OrdinalIgnoreCase);
var changed = false;
if (IsDevicePasswordGrant(values))
{
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
{
values["username"] = First(
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
"anonymous");
changed = true;
}
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
{
values["password"] = "__dorknet_device_login__";
values["dorknet_device_login"] = "true";
changed = true;
}
}
var scopes = values.TryGetValue("scope", out var rawScope)
? rawScope.ToString()
: string.Empty;
var mergedScopes = MergeScopes(scopes);
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
{
values["scope"] = mergedScopes;
changed = true;
}
if (changed)
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
await next(context);
}
private static bool IsTokenRequest(HttpContext context) =>
HttpMethods.IsPost(context.Request.Method)
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
&& context.Request.HasFormContentType;
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
{
if (!values.TryGetValue("grant_type", out var grantType) ||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
return false;
return !values.TryGetValue("password", out var password) ||
string.IsNullOrWhiteSpace(password.ToString());
}
private static string First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
private static string MergeScopes(string existing)
{
var scopes = existing
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
return string.Join(' ', scopes);
}
}
public sealed class RecRoomPasswordValidator(
PlayerService playerService,
LevelService level,
ServerSettingsService settings,
DorkNetDbContext db,
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
{
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
var raw = context.Request.Raw;
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
{
var byName = await playerService.GetByUsernameAsync(context.UserName);
if (byName is null || byName.PasswordHash is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown_user_or_no_password");
return;
}
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"password_mismatch");
return;
}
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
await TryAwardLoginXpAsync(byName.Id);
context.Result = await CreateSuccessAsync(byName, "password");
return;
}
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
? deviceId
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
effectiveDeviceId);
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"signups_disabled");
return;
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: platformId,
displayName: context.UserName);
await TryAwardLoginXpAsync(player.Id);
context.Result = await CreateSuccessAsync(player, "device_password");
}
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
new(
subject: player.Id.ToString(),
authenticationMethod: authMethod,
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class CachedLoginGrantValidator(
PlayerService playerService,
LevelService level,
OrphanAccountTracker orphans,
DorkNetDbContext db,
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
{
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var raw = context.Request.Raw;
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"missing account_id");
return;
}
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown account_id");
return;
}
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var pendingId = orphans.PeekPending(deviceId, platformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId,
orphanId);
}
orphans.Clear(deviceId, platformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
context.Result = new GrantValidationResult(
subject: picked.Id.ToString(),
authenticationMethod: GrantType,
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
}
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
{
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
if (!TryGetSubjectId(context.Subject, out var playerId))
return;
var player = await playerService.GetByIdAsync(playerId);
if (player is null)
return;
context.IssuedClaims.AddRange(
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
}
public async Task IsActiveAsync(IsActiveContext context)
{
context.IsActive =
TryGetSubjectId(context.Subject, out var playerId)
&& await playerService.GetByIdAsync(playerId) is not null;
}
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
{
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(value, out playerId);
}
}
public static class RecRoomIdentityClaims
{
public static readonly List<string> UserClaimTypes =
[
JwtRegisteredClaimNames.Sub,
ClaimTypes.NameIdentifier,
"name",
"role",
ClaimTypes.Role,
"roles",
"accountId",
"account_id",
"accountid",
];
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
{
var playerInfo = await db.Players
.Where(p => p.Id == playerId)
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
.FirstOrDefaultAsync();
var name = username ?? playerInfo?.Username ?? playerId.ToString();
var isDev = playerInfo?.IsDev ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
new("name", name),
new("accountId", playerId.ToString()),
new("account_id", playerId.ToString()),
new("accountid", playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("role", "gameClient"),
new("roles", "gameClient"),
};
if (isDev)
{
claims.Add(new Claim(ClaimTypes.Role, "developer"));
claims.Add(new Claim("role", "developer"));
claims.Add(new Claim("roles", "developer"));
}
return claims;
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Models.Notification; using DorkNet.Models.Notification;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
@@ -39,6 +40,7 @@ public class AdminController(
SignupCodeService signupCodes, SignupCodeService signupCodes,
IObjectStorage storage, IObjectStorage storage,
DomainConfig domain, DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger<AdminController> adminLogger) : ControllerBase ILogger<AdminController> adminLogger) : ControllerBase
{ {
private long CurrentAdminId => this.RequireCurrentPlayerId(); private long CurrentAdminId => this.RequireCurrentPlayerId();
@@ -1460,6 +1462,52 @@ public class AdminController(
// ── Server settings (runtime toggles) ──────────────────────────────── // ── Server settings (runtime toggles) ────────────────────────────────
[HttpGet("identityserver")]
public ActionResult GetIdentityServerSettings()
{
var issuer = domain.AuthIssuer.TrimEnd('/');
var client = RecRoomIdentityServerConfig.Clients[0];
var certPath = signingKeys.CertificatePath;
var fullCertPath = Path.GetFullPath(certPath);
var certificateExists = signingKeys.LoadedFromBase64 || System.IO.File.Exists(fullCertPath);
return Ok(new
{
Issuer = issuer,
DiscoveryUrl = $"{issuer}/.well-known/openid-configuration",
TokenEndpoint = $"{issuer}/connect/token",
UserInfoEndpoint = $"{issuer}/connect/userinfo",
Client = new
{
client.ClientId,
client.ClientName,
AllowedGrantTypes = client.AllowedGrantTypes.ToArray(),
AllowedScopes = client.AllowedScopes.ToArray(),
client.AllowOfflineAccess,
client.AccessTokenLifetime,
client.AbsoluteRefreshTokenLifetime,
client.SlidingRefreshTokenLifetime,
RefreshTokenUsage = client.RefreshTokenUsage.ToString(),
RefreshTokenExpiration = client.RefreshTokenExpiration.ToString(),
},
Signing = new
{
signingKeys.CertificateSource,
signingKeys.LoadedFromBase64,
CertificatePath = fullCertPath,
CertificateExists = certificateExists,
signingKeys.Certificate.Thumbprint,
NotBeforeUtc = signingKeys.Certificate.NotBefore.ToUniversalTime(),
NotAfterUtc = signingKeys.Certificate.NotAfter.ToUniversalTime(),
Algorithm = SecurityAlgorithms.RsaSha256,
},
PersistedGrants = new
{
Store = "in-memory",
Warning = "Refresh tokens are process-local until an operational store is configured.",
},
});
}
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide /// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
/// toggle state. Single row, single round trip.</summary> /// toggle state. Single row, single round trip.</summary>
[HttpGet("settings")] [HttpGet("settings")]
+47 -240
View File
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth; using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services; using DorkNet.Server.Services;
@@ -7,241 +6,18 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Auth; namespace DorkNet.Server.Controllers.Auth;
/// <summary> /// <summary>
/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin. /// auth.rec.net game-specific auth helpers. OAuth/OIDC endpoints are owned by IdentityServer.
/// </summary> /// </summary>
[ApiController] [ApiController]
public class AuthController( public class AuthController(
PlayerService playerService, PlayerService playerService,
AuthService authService, AuthService authService) : ControllerBase
LevelService level,
OrphanAccountTracker orphans,
ServerSettingsService settings,
DorkNet.Server.Data.DorkNetDbContext db,
DomainConfig domain,
ILogger<AuthController> logger) : ControllerBase
{ {
/// <summary>Award the per-day first-login XP if the calling
/// player hasn't been seen yet today (UTC).</summary>
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
// OAuth token endpoint. The 2020 client uses three grant types here:
// - grant_type=cached_login → user clicked a remembered account on the
// account-selection screen. Body includes `account_id` (and platform
// creds). MUST issue a token for THAT specific account; otherwise the
// client receives a JWT for a different account and prompts the user
// to create a new one (because the JWT identity doesn't match what
// they picked).
// - grant_type=refresh_token → re-issue access token from the existing
// refresh JWT.
// - grant_type=password → first-time sign-in, resolve via deviceId
// (preferred) or fall back to a stable synthetic id derived from
// username so legacy callers don't share accounts.
[HttpPost("/connect/token")]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token(
[FromForm] string? grant_type,
[FromForm] string? username,
[FromForm] string? password,
[FromForm] string? refresh_token,
[FromForm] string? deviceId,
[FromForm(Name = "device_id")] string? device_id_snake,
[FromForm] int platform = 0,
[FromForm] string? platformId = null,
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
[FromForm] long? account_id = null)
{
// The client mixes camelCase and snake_case parameter naming
// depending on grant type — accept both and merge.
var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake;
var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake;
if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase))
{
if (account_id is not long pickedId)
return BadRequest(new { error = "invalid_request", error_description = "missing account_id" });
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" });
// Orphan cleanup: if the watch just created a brand-new
// account on account/create as part of its boot probe, but
// the user actually picked a different cached account here,
// the just-created row is unused. Delete it so the DB
// doesn't accumulate one orphan per game launch. When the
// user is genuinely creating a new account, the picked id
// matches the just-created id and we leave it.
var pendingId = orphans.PeekPending(effDeviceId, effPlatformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId, orphanId);
}
orphans.Clear(effDeviceId, effPlatformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId);
var (acc1, refr1) = authService.GenerateTokenPair(picked.Id);
return Ok(new
{
access_token = acc1,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr1,
scope = "openid profile",
});
}
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(refresh_token))
return BadRequest(new { error = "invalid_request" });
var resolved = authService.ValidateToken(refresh_token);
if (resolved is not long refreshedId)
return Unauthorized(new { error = "invalid_grant" });
var existing = await playerService.GetByIdAsync(refreshedId);
if (existing is null)
return Unauthorized(new { error = "invalid_grant" });
var (acc, refr) = authService.GenerateTokenPair(existing.Id);
return Ok(new
{
access_token = acc,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refr,
scope = "openid profile",
});
}
// password grant: two paths.
// • Username + password supplied: real password verify against
// PlayerEntity.PasswordHash. Wrong password → 401.
// • No password: legacy device-id synth path for the 2020
// client's first-launch flow where the player hasn't set a
// password yet (HasPassword returns false in the watch UI
// and prompts them to create one). Resolves the existing
// account (or creates a fresh one) by deviceId.
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
{
var byName = await playerService.GetByUsernameAsync(username);
if (byName is null || byName.PasswordHash is null)
return Unauthorized(new
{
error = "invalid_grant",
error_description = "unknown_user_or_no_password",
});
if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
return Unauthorized(new
{
error = "invalid_grant",
error_description = "password_mismatch",
});
// Tag the platform so the next boot's cached-login lookup
// finds this account — without this the watch's "remembered
// accounts" prompt would stay empty after a manual sign-in.
await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId);
await TryAwardLoginXpAsync(byName.Id);
var pair = authService.GenerateTokenPair(byName.Id);
return Ok(new
{
access_token = pair.AccessToken,
token_type = "Bearer",
expires_in = 43200,
refresh_token = pair.RefreshToken,
scope = "openid profile",
});
}
// Device-id fallback path. Prefer the real deviceId; fall back
// to a stable legacy synthesis from username so older callers
// don't accidentally share an account.
var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId)
? effDeviceId
: $"legacy-{(username ?? "anonymous").ToLowerInvariant()}";
// Honour the admin "signups disabled" toggle. If the device
// hasn't logged in before, GetOrCreateByDeviceAsync would mint
// a fresh account here — same outcome as hitting
// /account/create directly, which the toggle blocks. Refuse
// BEFORE creation so the flag isn't bypassable.
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] /connect/token device fallback refused — signups disabled (device={Device})",
effectiveDeviceId);
return Unauthorized(new
{
error = "signups_disabled",
error_description = "Account creation is currently disabled by the server admin.",
});
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: effPlatformId,
displayName: username);
await TryAwardLoginXpAsync(player.Id);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
access_token = access,
token_type = "Bearer",
expires_in = 43200,
refresh_token = refresh,
scope = "openid profile",
});
}
[HttpGet("/connect/userinfo")]
public async Task<IActionResult> UserInfo()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
return Ok(new { sub = player.Id.ToString(), name = player.Username });
}
// Discovery document — issuer + endpoints derived from
// DomainConfig so a single DORKNET_DOMAIN env var keeps the
// OpenID document pointed at the live deployment.
[HttpGet("/.well-known/openid-configuration")]
public IActionResult OpenIdConfig()
{
var base_ = $"https://{domain.Sub("auth")}";
return Ok(new
{
issuer = base_,
token_endpoint = $"{base_}/connect/token",
userinfo_endpoint = $"{base_}/connect/userinfo",
});
}
// 2020.12 added an EAC challenge step in the login flow. The client GETs // 2020.12 added an EAC challenge step in the login flow. The client GETs
// a challenge string and passes it to EACManager.GenerateChallengeResponse, // a challenge string and passes it to EACManager.GenerateChallengeResponse,
// which calls System.Convert.FromBase64CharPtr on the string. So the // which calls System.Convert.FromBase64CharPtr on the string. So the
// returned challenge MUST be valid base64 — sending arbitrary text like // returned challenge MUST be valid base64 — sending arbitrary text throws
// "dorknet-eac-disabled" throws FormatException client-side and kicks // FormatException client-side and kicks the login.
// the login. 24 chars of base64 'A's decodes to 18 zero bytes; matches
// the rough size of a real EAC handshake nonce.
[HttpGet("/eac/challenge")] [HttpGet("/eac/challenge")]
[HttpPost("/eac/challenge")] [HttpPost("/eac/challenge")]
public IActionResult EacChallenge() public IActionResult EacChallenge()
@@ -250,11 +26,6 @@ public class AuthController(
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager. // Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId} // URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts. // Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
//
// Real lookup: any account whose last platformlogin matched
// (platform, platformId) shows up here, sorted most-recent-first. On a
// single-Steam-emu setup that's one row; on a multi-account dev box
// it's the full list the user has cycled through.
[HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")] [HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")]
public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId) public async Task<IActionResult> CachedLoginsForPlatform(int platform, string platformId)
{ {
@@ -264,10 +35,8 @@ public class AuthController(
/// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant /// <summary>POST <c>cachedlogin/forplatformids</c> — bulk variant
/// the watch uses when hydrating multiple platform-id mappings /// the watch uses when hydrating multiple platform-id mappings
/// at once (e.g. populating a friend list from Steam ids). Body /// at once. Body is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c>
/// is form-urlencoded <c>platform=&amp;platformIds=a,b,c</c> or /// or JSON with the same shape.</summary>
/// JSON with the same shape. Returns the flattened
/// <c>List&lt;CachedLogin&gt;</c> across every matched id.</summary>
[HttpPost("/cachedlogin/forplatformids")] [HttpPost("/cachedlogin/forplatformids")]
[HttpGet("/cachedlogin/forplatformids")] [HttpGet("/cachedlogin/forplatformids")]
public async Task<IActionResult> CachedLoginsForPlatformIds() public async Task<IActionResult> CachedLoginsForPlatformIds()
@@ -285,6 +54,7 @@ public class AuthController(
} }
} }
catch { /* not form */ } catch { /* not form */ }
if (string.IsNullOrWhiteSpace(rawIds)) if (string.IsNullOrWhiteSpace(rawIds))
{ {
int.TryParse(Request.Query["platform"], out platform); int.TryParse(Request.Query["platform"], out platform);
@@ -307,13 +77,50 @@ public class AuthController(
return Ok(results); return Ok(results);
} }
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new() [HttpGet("/cachedlogin/current")]
public async Task<IActionResult> CurrentCachedLogin()
{
var token = GetBearerToken();
if (token is null) return Unauthorized();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
return Ok(ToCachedLogin(player, player.LastPlatform, player.LastPlatformId));
}
[HttpPost("/cachedlogin/migrate")]
public async Task<IActionResult> MigrateCachedLogin(
[FromForm] int platform = 0,
[FromForm] string? platformId = null)
{
var token = GetBearerToken();
if (token is null) return Unauthorized();
var id = authService.ValidateToken(token);
if (id is not long playerId) return Unauthorized();
await playerService.TagPlatformAsync(playerId, platform, platformId);
var player = await playerService.GetByIdAsync(playerId);
return player is null
? Unauthorized()
: Ok(ToCachedLogin(player, platform, platformId ?? string.Empty));
}
private string? GetBearerToken()
{
var auth = Request.Headers.Authorization.ToString();
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return auth["Bearer ".Length..].Trim();
return Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken)
? cookieToken
: null;
}
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string? platformId) => new()
{ {
Platform = platform, Platform = platform,
PlatformId = platformId, PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id, AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt, LastLoginTime = p.LastSeenAt,
RequirePassword = false, RequirePassword = false,
}; };
} }
@@ -20,7 +20,6 @@ namespace DorkNet.Server.Controllers.Match;
[ApiController] [ApiController]
public class MatchPlayerController( public class MatchPlayerController(
PlayerPresenceService presence, PlayerPresenceService presence,
NotificationService notifications,
RoomService rooms, RoomService rooms,
PrivateInstanceService privateInstances, PrivateInstanceService privateInstances,
IConfiguration config, IConfiguration config,
@@ -37,7 +36,7 @@ public class MatchPlayerController(
/// </summary> /// </summary>
[HttpPost("/player/login")] [HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock) public IActionResult PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
{ {
var playerId = TryGetCurrentPlayerId(); var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock)) if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
@@ -46,17 +45,15 @@ public class MatchPlayerController(
if (replaced) if (replaced)
{ {
log.LogWarning( log.LogWarning(
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push", "[player-login] concurrent session marker replaced for player={PlayerId}; stale SignalR connections will be evicted on hub connect",
playerId); playerId);
// The new session hasn't opened its SignalR socket yet // Do not broadcast ModerationKick to the whole player group
// (negotiate fires AFTER /player/login on the watch's // here. During reconnects the active watch can already have a
// boot sequence — verified in the live trace), so the // SignalR connection in that group, and the 2020 client crashes
// ModerationKick push hits only the OLDER session's // hard if it receives a malformed/stale moderation payload.
// existing connections. They'll show the "you've been // NotifyHub.OnConnectedAsync snapshots existing connection ids
// kicked" dialog and gracefully disconnect via the // before adding the new connection and sends a targeted kick to
// watch's LocalModerationKickSelf path. // those ids only.
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
} }
} }
return Ok(Array.Empty<object>()); return Ok(Array.Empty<object>());
+12 -9
View File
@@ -1,20 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> <PackageReference Include="Open.IdentityServer" Version="1.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" /> <PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4"> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production <!-- RenderedCompactJsonFormatter for stdout JSON logs in production
(Coolify-friendly). The host pipeline reads stdout line-by-line, (Coolify-friendly). The host pipeline reads stdout line-by-line,
and structured JSON lets a future Loki/Datadog ingest parse fields and structured JSON lets a future Loki/Datadog ingest parse fields
@@ -25,8 +27,8 @@
scale: SignalR group fanout, OrphanAccountTracker (sticky-session scale: SignalR group fanout, OrphanAccountTracker (sticky-session
account-creation flow), and PlayerPresenceService heartbeats all account-creation flow), and PlayerPresenceService heartbeats all
need shared state across replicas. --> need shared state across replicas. -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" /> <PackageReference Include="StackExchange.Redis" Version="2.13.17" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.9" />
<!-- S3-compatible object storage. Production target: Garage in <!-- S3-compatible object storage. Production target: Garage in
Coolify (one-click). Dev fallback: local MinIO for parity Coolify (one-click). Dev fallback: local MinIO for parity
testing. The SDK speaks plain S3, so swapping endpoints testing. The SDK speaks plain S3, so swapping endpoints
@@ -38,7 +40,7 @@
cost+salt in the output string so we only need one column. --> cost+salt in the output string so we only need one column. -->
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N <!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
on profile thumbnails). The 2020 client treats this as part of the on profile thumbnails). The 2020 client treats this as part of the
image CDN contract — it uploads a full-frame capture and expects the image CDN contract — it uploads a full-frame capture and expects the
@@ -73,6 +75,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
+19 -33
View File
@@ -3,12 +3,16 @@ using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
namespace DorkNet.Server.Services; namespace DorkNet.Server.Services;
public class AuthService(IConfiguration config, DorkNetDbContext db) public class AuthService(IConfiguration config, DorkNetDbContext db, DomainConfig domain, IdentityServerSigningKeyProvider signingKeyProvider)
{ {
public const string AccessCookieName = "dorknet_access_token";
public const string LegacyIssuer = "https://api.rec.net/";
// Resolution order MUST match Program.cs's JwtBearer validator setup — // Resolution order MUST match Program.cs's JwtBearer validator setup —
// env var first, config key second — otherwise tokens are signed with // env var first, config key second — otherwise tokens are signed with
// one secret and validated against another and every authenticated // one secret and validated against another and every authenticated
@@ -38,38 +42,17 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType) private string CreateJwt(long playerId, TimeSpan lifetime, string tokenType)
{ {
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)); var claims = RecRoomIdentityClaims.CreateAsync(db, playerId)
.GetAwaiter()
// Look up dev/admin flags so the JWT carries a "developer" role .GetResult();
// claim for accounts that should see the in-game debug button. claims.Add(new Claim("token_type", tokenType));
// The watch's Login.SetAccessToken parses the access_token's
// <c>role</c> claim — either a single string or a list — and
// calls <c>HasRole("developer", token)</c>; that result drives
// <c>Accounts.SetLocalAccountIsDev</c> and ultimately
// <c>SessionManager.IsDeveloper</c>, which the watch UI checks
// when deciding whether to show the dev console toggle.
var isDev = db.Players
.Where(p => p.Id == playerId)
.Select(p => (bool?)(p.IsDeveloper || p.IsAdmin))
.FirstOrDefault() ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("token_type", tokenType),
};
// Multiple Role claims become a JSON array on the wire — the
// watch's HasRole helper handles both string and List<string>
// forms.
if (isDev) claims.Add(new Claim(ClaimTypes.Role, "developer"));
var descriptor = new SecurityTokenDescriptor var descriptor = new SecurityTokenDescriptor
{ {
Subject = new ClaimsIdentity(claims), Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.Add(lifetime), Expires = DateTime.UtcNow.Add(lifetime),
Issuer = "https://api.rec.net/", Issuer = domain.AuthIssuer,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256), SigningCredentials = new SigningCredentials(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256),
}; };
return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor)); return handler.WriteToken(handler.CreateJwtSecurityToken(descriptor));
@@ -80,19 +63,22 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
try try
{ {
var handler = new JwtSecurityTokenHandler(); var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
var principal = handler.ValidateToken(token, new TokenValidationParameters var principal = handler.ValidateToken(token, new TokenValidationParameters
{ {
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = key, IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/", ValidIssuers = new[] { domain.AuthIssuer, LegacyIssuer },
ValidateAudience = false, ValidateAudience = false,
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
}, out _); }, out _);
var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; var idClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
?? principal.FindFirst("sub")?.Value
?? principal.FindFirst("accountId")?.Value
?? principal.FindFirst("account_id")?.Value
?? principal.FindFirst("accountid")?.Value;
return idClaim is not null ? long.Parse(idClaim) : null; return idClaim is not null ? long.Parse(idClaim) : null;
} }
catch catch
+3
View File
@@ -20,6 +20,9 @@ public sealed class DomainConfig
} }
public string Sub(string prefix) => $"{prefix}.{Apex}"; public string Sub(string prefix) => $"{prefix}.{Apex}";
public string AuthIssuer => Url("auth");
public string Url(string prefix, string path = "") public string Url(string prefix, string path = "")
{ {
var host = string.IsNullOrEmpty(prefix) ? Apex : Sub(prefix); var host = string.IsNullOrEmpty(prefix) ? Apex : Sub(prefix);
+28 -1
View File
@@ -64,7 +64,12 @@ public class GameSessionService(DorkNetDbContext db)
// in, so we save once with a placeholder, then patch and save // in, so we save once with a placeholder, then patch and save
// again. Two SaveChangesAsync calls is cheap at our QPS. // again. Two SaveChangesAsync calls is cheap at our QPS.
PhotonRoomName = "pending", PhotonRoomName = "pending",
MaxCapacity = 8, // 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),
PlayerCount = 1, PlayerCount = 1,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}; };
@@ -75,6 +80,28 @@ public class GameSessionService(DorkNetDbContext db)
return ToDto(session); 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) public async Task<GameSession?> GetByIdAsync(long id)
{ {
var s = await db.GameSessions.AsNoTracking() var s = await db.GameSessions.AsNoTracking()
@@ -87,6 +87,9 @@ public static class MiddlewarePipelineExtensions
// calls (login, photon custom auth) are gated too. // calls (login, photon custom auth) are gated too.
app.UseMiddleware<VersionDetectionMiddleware>(); app.UseMiddleware<VersionDetectionMiddleware>();
app.UseMiddleware<IdentityServerGameTokenRequestMiddleware>();
app.UseIdentityServer();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// Sits between auth and the controllers — by this point ctx.User has // Sits between auth and the controllers — by this point ctx.User has
@@ -1,6 +1,8 @@
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.Auth;
using DorkNet.Server.Compat; using DorkNet.Server.Compat;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Services; using DorkNet.Server.Services;
@@ -23,11 +25,11 @@ public static class ServiceCollectionExtensions
{ {
ConfigureLogging(builder); ConfigureLogging(builder);
AddDatabase(builder); AddDatabase(builder);
AddDomain(builder); var domain = AddDomain(builder);
AddCoreServices(builder.Services); AddCoreServices(builder.Services);
AddVersionCompatibility(builder); AddVersionCompatibility(builder);
var redisConn = AddRedis(builder); var redisConn = AddRedis(builder);
AddAuthentication(builder); AddAuthentication(builder, domain);
AddKestrelAndSignalR(builder, redisConn); AddKestrelAndSignalR(builder, redisConn);
} }
@@ -100,21 +102,27 @@ public static class ServiceCollectionExtensions
// Replaces per-controller [Host(...)] filters that hard-coded // Replaces per-controller [Host(...)] filters that hard-coded
// rec.net/localhost pairs — the singleton DomainConfig is injected // rec.net/localhost pairs — the singleton DomainConfig is injected
// anywhere code needs to build outbound URLs. // anywhere code needs to build outbound URLs.
private static void AddDomain(WebApplicationBuilder builder) private static DomainConfig AddDomain(WebApplicationBuilder builder)
{ {
var apex = var apex =
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"; var scheme = builder.Configuration["Domain:Scheme"] ?? "https";
builder.Services.AddSingleton(new DomainConfig(apex, scheme)); var domain = new DomainConfig(apex, scheme);
builder.Services.AddSingleton(domain);
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)}]");
return domain;
} }
// ── Services ────────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────────
@@ -247,15 +255,11 @@ public static class ServiceCollectionExtensions
// Never commit a real secret to appsettings.json. DORKNET_JWT_SECRET is the // Never commit a real secret to appsettings.json. DORKNET_JWT_SECRET is the
// canonical env var name; RECNET_JWT_SECRET kept for backward compat with // canonical env var name; RECNET_JWT_SECRET kept for backward compat with
// older Coolify configs. // older Coolify configs.
private static void AddAuthentication(WebApplicationBuilder builder) private static void AddAuthentication(WebApplicationBuilder builder, DomainConfig domain)
{ {
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET") var signingKeyProvider = new IdentityServerSigningKeyProvider(builder.Configuration);
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET") builder.Services.AddSingleton(signingKeyProvider);
?? builder.Configuration["Jwt:Secret"] builder.Services.AddRecRoomIdentityServer(builder.Configuration, domain, signingKeyProvider);
?? throw new InvalidOperationException(
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
"add `Jwt:Secret` to appsettings.Local.json (the install-plugin.ps1 " +
"script generates one automatically on same-machine setup).");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt => .AddJwtBearer(opt =>
@@ -263,9 +267,9 @@ public static class ServiceCollectionExtensions
opt.TokenValidationParameters = new TokenValidationParameters opt.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), IssuerSigningKeys = signingKeyProvider.ValidationKeys,
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/", ValidIssuers = new[] { domain.AuthIssuer, AuthService.LegacyIssuer },
ValidateAudience = false, ValidateAudience = false,
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
}; };
@@ -285,6 +289,12 @@ public static class ServiceCollectionExtensions
var path = ctx.HttpContext.Request.Path; var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub")) if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub"))
ctx.Token = accessToken; ctx.Token = accessToken;
if (string.IsNullOrEmpty(ctx.Token) &&
ctx.Request.Cookies.TryGetValue(AuthService.AccessCookieName, out var cookieToken) &&
!string.IsNullOrWhiteSpace(cookieToken))
{
ctx.Token = cookieToken;
}
return Task.CompletedTask; return Task.CompletedTask;
}, },
}; };
@@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { get } from '../lib/api';
import { RefreshCw } from '../components/Icons';
interface IdentityServerSettings {
issuer: string;
discoveryUrl: string;
tokenEndpoint: string;
userInfoEndpoint: string;
client: {
clientId: string;
clientName: string;
allowedGrantTypes: string[];
allowedScopes: string[];
allowOfflineAccess: boolean;
accessTokenLifetime: number;
absoluteRefreshTokenLifetime: number;
slidingRefreshTokenLifetime: number;
refreshTokenUsage: string;
refreshTokenExpiration: string;
};
signing: {
certificateSource: string;
loadedFromBase64: boolean;
certificatePath: string;
certificateExists: boolean;
thumbprint: string;
notBeforeUtc: string;
notAfterUtc: string;
algorithm: string;
};
persistedGrants: {
store: string;
warning: string;
};
}
export function IdentityServer() {
const [settings, setSettings] = useState<IdentityServerSettings | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = async () => {
setBusy(true);
try {
const data = await get<IdentityServerSettings>('/identityserver');
setSettings(data);
setErr(null);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
useEffect(() => { void load(); }, []);
return (
<div>
<div className="mb-3 flex justify-end">
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
)}
{!settings && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading...</div>
)}
{settings && (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-5">
<div className="card !p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold text-ink-50">IdentityServer</h2>
<p className="mt-1 break-all font-mono text-xs text-ink-300">{settings.issuer}</p>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<StatusBadge ok={settings.signing.certificateExists} on="Signing key" off="Missing key" />
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
<Endpoint label="Discovery" value={settings.discoveryUrl} />
<Endpoint label="Token" value={settings.tokenEndpoint} />
<Endpoint label="UserInfo" value={settings.userInfoEndpoint} />
</div>
</div>
<div className="card !p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ink-50">Client</h2>
<p className="mt-1 font-mono text-xs text-ink-400">{settings.client.clientId}</p>
</div>
<span className={settings.client.allowOfflineAccess ? 'badge-online' : 'badge-neutral'}>
Offline access {settings.client.allowOfflineAccess ? 'on' : 'off'}
</span>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ChipList title="Grant types" values={settings.client.allowedGrantTypes} />
<ChipList title="Scopes" values={settings.client.allowedScopes} />
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
<Metric label="Access token" value={duration(settings.client.accessTokenLifetime)} />
<Metric label="Refresh token" value={duration(settings.client.absoluteRefreshTokenLifetime)} />
<Metric label="Refresh mode" value={`${settings.client.refreshTokenUsage} / ${settings.client.refreshTokenExpiration}`} />
</div>
</div>
</div>
<div className="space-y-5">
<div className="card !p-5">
<h2 className="text-sm font-semibold text-ink-50">Signing certificate</h2>
<div className="mt-4 space-y-3">
<Detail label="Source" value={settings.signing.certificateSource} mono />
{!settings.signing.loadedFromBase64 && (
<Detail label="Path" value={settings.signing.certificatePath} mono />
)}
<Detail label="Thumbprint" value={settings.signing.thumbprint} mono />
<Detail label="Algorithm" value={settings.signing.algorithm} mono />
<Detail label="Valid from" value={formatDate(settings.signing.notBeforeUtc)} />
<Detail label="Valid until" value={formatDate(settings.signing.notAfterUtc)} />
</div>
</div>
<div className="card !p-5">
<h2 className="text-sm font-semibold text-ink-50">Operational store</h2>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-xs text-ink-400">Persisted grants</span>
<span className="badge-junior">{settings.persistedGrants.store}</span>
</div>
<p className="mt-4 text-xs leading-5 text-ink-400">{settings.persistedGrants.warning}</p>
</div>
</div>
</div>
)}
</div>
);
}
function Endpoint({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/40 p-3">
<div className="label">{label}</div>
<div className="mt-2 break-all font-mono text-xs text-ink-100">{value}</div>
</div>
);
}
function ChipList({ title, values }: { title: string; values: string[] }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase text-ink-400">{title}</h3>
<div className="mt-3 flex flex-wrap gap-2">
{values.map((value) => (
<span key={value} className="badge-neutral font-mono normal-case tracking-normal">{value}</span>
))}
</div>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<div className="text-[11px] uppercase text-ink-500">{label}</div>
<div className="mt-1 text-sm font-semibold text-ink-50">{value}</div>
</div>
);
}
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="label">{label}</div>
<div className={(mono ? 'font-mono ' : '') + 'mt-1 break-all text-xs text-ink-100'}>{value}</div>
</div>
);
}
function StatusBadge({ ok, on, off }: { ok: boolean; on: string; off: string }) {
return <span className={ok ? 'badge-online' : 'badge-junior'}>{ok ? on : off}</span>;
}
function formatDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function duration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m`;
return `${seconds}s`;
}
@@ -1,4 +1,5 @@
import { Settings } from './Settings'; import { Settings } from './Settings';
import { IdentityServer } from './IdentityServer';
import { SignupCodes } from './SignupCodes'; import { SignupCodes } from './SignupCodes';
import { PageHeader } from '../components/PageHeader'; import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs'; import { Tabs, useTabParam } from '../components/Tabs';
@@ -7,19 +8,21 @@ import { Tabs, useTabParam } from '../components/Tabs';
// invite codes (an access-control setting in its own right). Old // invite codes (an access-control setting in its own right). Old
// /signup-codes route redirects here with ?tab=signup. // /signup-codes route redirects here with ?tab=signup.
export function SettingsHome() { export function SettingsHome() {
const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']); const [tab, setTab] = useTabParam<'server' | 'identity' | 'signup'>('server', ['server', 'identity', 'signup']);
return ( return (
<div> <div>
<PageHeader title="Settings" blurb="Runtime server toggles and single-use signup invite codes." /> <PageHeader title="Settings" blurb="Runtime server toggles, authentication, and single-use signup invite codes." />
<Tabs <Tabs
tabs={[ tabs={[
{ key: 'server', label: 'Server' }, { key: 'server', label: 'Server' },
{ key: 'identity', label: 'IdentityServer' },
{ key: 'signup', label: 'Signup codes' }, { key: 'signup', label: 'Signup codes' },
]} ]}
active={tab} active={tab}
onChange={setTab} onChange={setTab}
/> />
{tab === 'server' && <Settings embedded />} {tab === 'server' && <Settings embedded />}
{tab === 'identity' && <IdentityServer />}
{tab === 'signup' && <SignupCodes embedded />} {tab === 'signup' && <SignupCodes embedded />}
</div> </div>
); );
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+5 -1
View File
@@ -72,13 +72,17 @@ cd DorkNet
| `S3:SecretKey` | `S3__SecretKey` | production | S3 secret key | | `S3:SecretKey` | `S3__SecretKey` | production | S3 secret key |
| `S3:Region` | `S3__Region` | no | defaults to `garage`; use `auto` for R2 | | `S3:Region` | `S3__Region` | no | defaults to `garage`; use `auto` for R2 |
`/connect/token` issues access and refresh tokens with the configured
auth host as the issuer. Set `DORKNET_DOMAIN` for production so OpenID
discovery and JWT validation agree on `https://auth.<domain>`.
**4. Boot a local standalone server:** **4. Boot a local standalone server:**
```pwsh ```pwsh
dotnet run --project DorkNet.Server dotnet run --project DorkNet.Server
``` ```
First boot creates `bin/Debug/net9.0/data/dorknet.db`, runs every First boot creates `bin/Debug/net10.0/data/dorknet.db`, runs every
`LegacyUpgrades` pass, seeds canonical rooms / store catalog / club `LegacyUpgrades` pass, seeds canonical rooms / store catalog / club
categories / playlists, and starts listening. You should see categories / playlists, and starts listening. You should see
`Application started.` in the log; `curl http://localhost:8080/healthz` `Application started.` in the log; `curl http://localhost:8080/healthz`
+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
+5 -1
View File
@@ -38,7 +38,7 @@ DorkNet.sln
| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 9 | | `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 9 |
| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 9 | | `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 9 |
| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 9 | | `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 9 |
| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 9 | | `DorkNet.Services.Identity` | Accounts, auth, platform login, OpenID discovery, JWT issuance route slice | .NET 9 |
| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 9 | | `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 9 |
| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 9 | | `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 9 |
| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 | | `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 |
@@ -113,6 +113,10 @@ Compose network and forwards Cloudflare Tunnel traffic to
configured with `DORKNET_S3_*` environment variables; the compose files configured with `DORKNET_S3_*` environment variables; the compose files
do not run MinIO or Garage. do not run MinIO or Garage.
The identity slice is the token issuer in the service split. Discovery at
`/.well-known/openid-configuration` advertises the configured auth host,
and access/refresh tokens minted by `/connect/token` use that same issuer.
`Dockerfile.service` is the reusable service image. It accepts `Dockerfile.service` is the reusable service image. It accepts
`PROJECT_PATH` and `APP_DLL` build args, rebuilds the admin/site Vite `PROJECT_PATH` and `APP_DLL` build args, rebuilds the admin/site Vite
SPAs for service images that need static assets, publishes the selected SPAs for service images that need static assets, publishes the selected
+5
View File
@@ -42,6 +42,11 @@ shared `DorkNet.Server` controller/service stack behind route ownership
guards, so response contracts stay identical while the architecture is guards, so response contracts stay identical while the architecture is
split. split.
Auth tokens are issued by the identity service using the configured
auth host (`https://auth.<apex>` or the equivalent configured host style)
as the JWT/OpenID issuer. `DORKNET_JWT_SECRET` still supplies the signing
key for every service that validates bearer tokens.
--- ---
## Dokploy Microservices ## Dokploy Microservices
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+2 -2
View File
@@ -51,8 +51,8 @@ static Assembly LoadServerAssembly(string[] args)
var repositoryRoot = FindRepositoryRoot(); var repositoryRoot = FindRepositoryRoot();
var candidates = new[] var candidates = new[]
{ {
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"), Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net10.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll") Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net10.0", "DorkNet.Server.dll")
}; };
var existingCandidates = candidates.Where(File.Exists).ToArray(); var existingCandidates = candidates.Where(File.Exists).ToArray();
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+2 -2
View File
@@ -333,8 +333,8 @@ static Assembly LoadServerAssembly(string? configuredPath)
var repositoryRoot = FindRepositoryRoot(); var repositoryRoot = FindRepositoryRoot();
var candidates = new[] var candidates = new[]
{ {
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"), Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net10.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll") Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net10.0", "DorkNet.Server.dll")
}; };
var existingCandidates = candidates.Where(File.Exists).ToArray(); var existingCandidates = candidates.Where(File.Exists).ToArray();
if (existingCandidates.Length == 0) if (existingCandidates.Length == 0)
+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',