diff --git a/.github/workflows.disabled/endpoint-contract.yml b/.github/workflows.disabled/endpoint-contract.yml new file mode 100644 index 0000000..1fc41ea --- /dev/null +++ b/.github/workflows.disabled/endpoint-contract.yml @@ -0,0 +1,78 @@ +# Disabled template. GitHub ignores files outside .github/workflows/. +# Move this file to .github/workflows/endpoint-contract.yml when it should run. +name: Endpoint Contract + +on: + workflow_dispatch: + +jobs: + endpoint-contract: + runs-on: ubuntu-latest + + env: + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_URLS: http://127.0.0.1:18080 + Database__Provider: sqlite + Database__SqlitePath: ${{ runner.temp }}/dorknet-endpoint-contract.db + DORKNET_JWT_SECRET: endpoint-contract-secret-000000000000000000000000000000000000000000 + S3__Endpoint: "" + S3__AccessKey: "" + S3__SecretKey: "" + PROTOBUF_PROTOC: /usr/bin/protoc + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Install protobuf compiler + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + + - name: Build server + run: dotnet build DorkNet.Server/DorkNet.Server.csproj + + - name: Build endpoint contract runner + run: dotnet build tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-restore + + - name: Start server + run: | + dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 & + echo "$!" > "$RUNNER_TEMP/dorknet-server.pid" + + - name: Wait for server readiness + run: | + for attempt in {1..60}; do + if curl --fail --silent http://127.0.0.1:18080/healthz; then + exit 0 + fi + sleep 2 + done + cat "$RUNNER_TEMP/dorknet-server.log" + exit 1 + + - name: Run endpoint contract + run: | + dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \ + --server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \ + --base-url http://127.0.0.1:18080 \ + --report "$RUNNER_TEMP/endpoint-contract-report.json" + + - name: Upload endpoint contract artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: endpoint-contract + path: | + ${{ runner.temp }}/endpoint-contract-report.json + ${{ runner.temp }}/dorknet-server.log + + - name: Stop server + if: always() + run: | + if [ -f "$RUNNER_TEMP/dorknet-server.pid" ]; then + kill "$(cat "$RUNNER_TEMP/dorknet-server.pid")" || true + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec5cc46..eae2d14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,9 @@ on: tags: - 'v1-december-*' +permissions: + contents: write # create the GitHub release + upload assets + jobs: release: runs-on: windows-latest diff --git a/.gitignore b/.gitignore index 7ae432c..522b129 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,7 @@ tmp/ ## OS .DS_Store Thumbs.db - +.backups ## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit) dump.cs il2cpp.h @@ -48,6 +48,7 @@ dist$ZIPNAME.zip ## time someone rebuilds. Keep the source committed, regenerate output. DorkNet.Server/wwwroot/admin/index.html DorkNet.Server/wwwroot/admin/assets/ +DorkNet.Server/wwwroot/admin/brand/ DorkNet.Server/wwwroot/admin/*.map DorkNet.Server/admin-ui/node_modules/ DorkNet.Server/admin-ui/dist/ @@ -58,6 +59,7 @@ DorkNet.Server/admin-ui/tsconfig.tsbuildinfo ## Same reasoning as admin-ui above: hashed output, regenerate per build. DorkNet.Server/wwwroot/site/index.html DorkNet.Server/wwwroot/site/assets/ +DorkNet.Server/wwwroot/site/brand/ DorkNet.Server/wwwroot/site/*.map DorkNet.Server/site/node_modules/ DorkNet.Server/site/dist/ @@ -83,4 +85,4 @@ Recovered/ Recovered(*)/ -local-releases/** \ No newline at end of file +local-releases/** diff --git a/Dockerfile.service b/Dockerfile.service new file mode 100644 index 0000000..a1151d4 --- /dev/null +++ b/Dockerfile.service @@ -0,0 +1,55 @@ +ARG DOTNET_VERSION=9.0 +ARG NODE_VERSION=20 + +FROM node:${NODE_VERSION}-alpine AS admin-ui +WORKDIR /ui +COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./ +RUN npm install --no-fund --no-audit --loglevel=error +COPY DorkNet.Server/admin-ui/ ./ +RUN mkdir -p /wwwroot/admin \ + && npm run build + +FROM node:${NODE_VERSION}-alpine AS site +WORKDIR /ui +COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./ +RUN npm install --no-fund --no-audit --loglevel=error +COPY DorkNet.Server/site/ ./ +RUN mkdir -p /wwwroot/site \ + && npm run build + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build +ARG PROJECT_PATH +WORKDIR /src + +COPY . . +COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/ +COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/ +RUN dotnet publish "${PROJECT_PATH}" -c Release \ + -p:SkipAdminUIBuild=true \ + -p:DorkNetServiceContainerPublish=true \ + -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime +ARG APP_DLL +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +USER app + +COPY --from=build --chown=app:app /app/publish . + +ENV ASPNETCORE_ENVIRONMENT=Production \ + ASPNETCORE_URLS=http://0.0.0.0:8080 \ + DOTNET_RUNNING_IN_CONTAINER=true \ + DOTNET_PRINT_TELEMETRY_MESSAGE=false \ + APP_DLL=${APP_DLL} + +EXPOSE 8080 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \ + CMD curl --fail --silent http://localhost:8080/healthz || exit 1 + +ENTRYPOINT ["sh", "-c", "dotnet \"$APP_DLL\""] diff --git a/DorkNet.AdminMobile/README.md b/DorkNet.AdminMobile/README.md index 240490a..524f4a1 100644 --- a/DorkNet.AdminMobile/README.md +++ b/DorkNet.AdminMobile/README.md @@ -2,6 +2,16 @@ Native Android/iOS admin app for DorkNet, built with .NET MAUI. +In the microservices deployment, point the app at the public admin host +for the stack, usually `https://admin.`. Cloudflare and +`DorkNet.Gateway` keep the public URL stable. Requests made through the +admin host are handled by the `web` service's same-origin admin route; +the `/api/admin/*` path family is also exposed by the moderation slice +for non-admin-host routing. + +The browser admin page, route map, and troubleshooting flow are +documented in [`../docs/admin.md`](../docs/admin.md). + ## Authentication The app stores sensitive values in platform secure storage: diff --git a/DorkNet.Contracts/DorkNet.Contracts.csproj b/DorkNet.Contracts/DorkNet.Contracts.csproj new file mode 100644 index 0000000..9623deb --- /dev/null +++ b/DorkNet.Contracts/DorkNet.Contracts.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/DorkNet.Contracts/DorkNetRouteOwnership.cs b/DorkNet.Contracts/DorkNetRouteOwnership.cs new file mode 100644 index 0000000..b23474b --- /dev/null +++ b/DorkNet.Contracts/DorkNetRouteOwnership.cs @@ -0,0 +1,298 @@ +namespace DorkNet.Contracts; + +public static class DorkNetRouteOwnership +{ + private const string AdminHost = "admin"; + + private static readonly DorkNetRouteGroup[] ServiceRouteGroups = + [ + new( + ServiceNames.Identity, + ["auth", "accounts"], + [ + "/.well-known", + "/account", + "/api/account", + "/api/auth", + "/api/createaccount", + "/api/platformlogin", + "/api/playerreputation", + "/api/players", + "/api/relationships", + "/api/settings", + "/cachedlogin", + "/connect", + "/eac", + "/parentalcontrol", + "/photon", + "/profileimage", + "/settings", + ]), + new( + ServiceNames.Rooms, + ["rooms", "match", "discovery", "lists", "roomcomments", "roomieintegrations", "studio"], + [ + "/api/activities", + "/api/curatedroomplaylists", + "/api/gameconfigs", + "/api/gameconfigurations", + "/api/gamesessions", + "/api/leaderboard", + "/api/playerelo", + "/api/playlists", + "/api/roomkeys", + "/api/rooms", + "/api/royale", + "/discover", + "/featuredrooms", + "/goto", + "/hot_rooms", + "/hot_roomsandplaylists", + "/invite", + "/playlists", + "/player", + "/room", + "/roominstance", + "/rooms", + "/roomsandplaylists", + "/roomserver", + "/search_rooms", + "/search_roomsandplaylists", + "/v1/find", + "/v1/join", + "/v1/leave", + "/v1/session", + ]), + new( + ServiceNames.Notify, + ["notify", "chat", "platformnotifications"], + [ + "/api/messages", + "/api/notification", + "/api/offlineinvite", + "/hub", + "/notify", + "/thread", + ]), + new( + ServiceNames.Content, + ["cdn", "data", "img", "storage", "strings-cdn", "videos"], + [ + "/api/images", + "/api/photos", + "/config", + "/data", + "/img", + "/invention", + "/upload", + "/video", + ]), + new( + ServiceNames.Social, + ["clubs"], + [ + "/announcements", + "/api/announcement", + "/api/clubreporting", + "/api/communityboard", + "/api/groups", + "/api/playercheer", + "/api/playerevents", + "/api/playersubscriptions", + "/club", + "/members", + "/subscription", + ]), + new( + ServiceNames.Commerce, + ["commerce", "econ"], + [ + "/api/avatar", + "/api/catalog", + "/api/compatibility", + "/api/consumables", + "/api/equipment", + "/api/gamerewards", + "/api/inventions", + "/api/purchase", + "/api/storefronts", + "/commerce", + "/econ", + ]), + new( + ServiceNames.Platform, + ["cards", "cms", "datacollection", "gamelogs", "geo", "leaderboard", "link", "ns", "playersettings", "strings", "thorn"], + [ + "/api/cards", + "/api/config", + "/api/events", + "/api/quickplay", + "/api/role", + "/api/version", + "/api/versioncheck", + "/motd", + "/pageview", + "/ping", + "/role", + "/strings", + "/v1/ping", + "/v1/player", + "/v1/regions", + "/v1/services", + ]), + new( + ServiceNames.Moderation, + ["bugreporting", "moderation"], + [ + "/api/admin", + "/api/banappeal", + "/api/bugreporting", + "/api/moderation", + "/api/playerreporting", + "/api/playersbanned", + "/api/sanitize", + "/api/testcasemanagement", + ]), + new( + ServiceNames.Web, + ["www", AdminHost, "feed"], + [ + "/api/site", + ], + IncludesApexHost: true), + ]; + + public static IReadOnlyList RouteGroups => ServiceRouteGroups; + + public static bool IsInfrastructurePath(string path) + { + return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/internal/", StringComparison.OrdinalIgnoreCase); + } + + public static string ResolvePublicService(string? host, string path, string apexDomain) + { + var normalizedPath = NormalizePath(path); + var subdomain = Subdomain(host, apexDomain); + + if (string.Equals(subdomain, AdminHost, StringComparison.OrdinalIgnoreCase)) + { + return ServiceNames.Web; + } + + foreach (var group in ServiceRouteGroups.Where(group => group.ServiceName != ServiceNames.Web)) + { + if (MatchesHost(subdomain, group.HostSubdomains)) + { + return group.ServiceName; + } + } + + foreach (var group in ServiceRouteGroups) + { + if (StartsWithAny(normalizedPath, group.PathPrefixes)) + { + return group.ServiceName; + } + } + + var web = ServiceRouteGroups.First(group => group.ServiceName == ServiceNames.Web); + if ((subdomain is null && web.IncludesApexHost) || MatchesHost(subdomain, web.HostSubdomains)) + { + return ServiceNames.Web; + } + + return ServiceNames.Monolith; + } + + public static bool IsOwnedBy(string serviceName, string? host, string path, string apexDomain) + { + if (IsInfrastructurePath(path)) + { + return true; + } + + return string.Equals( + ResolvePublicService(host, path, apexDomain), + serviceName, + StringComparison.OrdinalIgnoreCase); + } + + public static string? Subdomain(string? host, string apexDomain) + { + if (string.IsNullOrWhiteSpace(host)) + { + return null; + } + + var normalizedHost = host.Split(':', 2)[0].Trim().ToLowerInvariant(); + var normalizedApex = apexDomain.Split(':', 2)[0].Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalizedHost) + || string.IsNullOrWhiteSpace(normalizedApex) + || string.Equals(normalizedHost, normalizedApex, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var suffix = "." + normalizedApex; + if (normalizedHost.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return normalizedHost[..^suffix.Length]; + } + + var dot = normalizedHost.IndexOf('.'); + return dot > 0 ? normalizedHost[..dot] : null; + } + + private static bool StartsWithAny(string path, IEnumerable prefixes) + { + foreach (var prefix in prefixes) + { + if (StartsWithPathSegment(path, prefix)) + { + return true; + } + } + + return false; + } + + private static bool StartsWithPathSegment(string path, string prefix) + { + var normalizedPath = NormalizePath(path); + var normalizedPrefix = NormalizePath(prefix).TrimEnd('/'); + if (string.IsNullOrWhiteSpace(normalizedPrefix)) + { + normalizedPrefix = "/"; + } + + return normalizedPath.Equals(normalizedPrefix, StringComparison.OrdinalIgnoreCase) + || normalizedPath.StartsWith(normalizedPrefix + "/", StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesHost(string? subdomain, IEnumerable candidates) + { + if (string.IsNullOrWhiteSpace(subdomain)) + { + return false; + } + + return candidates.Contains(subdomain, StringComparer.OrdinalIgnoreCase); + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "/"; + } + + return path[0] == '/' ? path : "/" + path; + } +} + +public sealed record DorkNetRouteGroup( + string ServiceName, + string[] HostSubdomains, + string[] PathPrefixes, + bool IncludesApexHost = false); diff --git a/DorkNet.Contracts/DorkNetServiceMapOptions.cs b/DorkNet.Contracts/DorkNetServiceMapOptions.cs new file mode 100644 index 0000000..bdc5119 --- /dev/null +++ b/DorkNet.Contracts/DorkNetServiceMapOptions.cs @@ -0,0 +1,38 @@ +namespace DorkNet.Contracts; + +public sealed class DorkNetServiceMapOptions +{ + public string Identity { get; set; } = "http://localhost:8081"; + public string Rooms { get; set; } = "http://localhost:8082"; + public string Notify { get; set; } = "http://localhost:8083"; + public string Content { get; set; } = "http://localhost:8084"; + public string Social { get; set; } = "http://localhost:8085"; + public string Commerce { get; set; } = "http://localhost:8086"; + public string Platform { get; set; } = "http://localhost:8087"; + public string Moderation { get; set; } = "http://localhost:8088"; + public string Web { get; set; } = "http://localhost:8089"; + public string Monolith { get; set; } = "http://localhost:8090"; + + public IReadOnlyList Endpoints() + { + DorkNetServiceEndpoint[] endpoints = + [ + new(ServiceNames.Identity, Identity), + new(ServiceNames.Rooms, Rooms), + new(ServiceNames.Notify, Notify), + new(ServiceNames.Content, Content), + new(ServiceNames.Social, Social), + new(ServiceNames.Commerce, Commerce), + new(ServiceNames.Platform, Platform), + new(ServiceNames.Moderation, Moderation), + new(ServiceNames.Web, Web), + new(ServiceNames.Monolith, Monolith), + ]; + + return endpoints + .Where(endpoint => !string.IsNullOrWhiteSpace(endpoint.BaseUrl)) + .ToArray(); + } +} + +public sealed record DorkNetServiceEndpoint(string Name, string BaseUrl); diff --git a/DorkNet.Contracts/ServiceContracts.cs b/DorkNet.Contracts/ServiceContracts.cs new file mode 100644 index 0000000..216e6a2 --- /dev/null +++ b/DorkNet.Contracts/ServiceContracts.cs @@ -0,0 +1,19 @@ +namespace DorkNet.Contracts; + +public sealed record ServiceHealthResponse( + string Service, + string Status, + DateTimeOffset CheckedAtUtc); + +public sealed record ServiceCapabilityResponse( + string Service, + string[] Owns, + string[] PlannedPublicRoutes); + +public sealed record ServiceProbeResponse( + string Service, + string BaseUrl, + string Status, + int? StatusCode, + string? Error, + DateTimeOffset CheckedAtUtc); diff --git a/DorkNet.Contracts/ServiceNames.cs b/DorkNet.Contracts/ServiceNames.cs new file mode 100644 index 0000000..79f63ef --- /dev/null +++ b/DorkNet.Contracts/ServiceNames.cs @@ -0,0 +1,16 @@ +namespace DorkNet.Contracts; + +public static class ServiceNames +{ + public const string Gateway = "gateway"; + public const string Identity = "identity"; + public const string Rooms = "rooms"; + public const string Notify = "notify"; + public const string Content = "content"; + public const string Social = "social"; + public const string Commerce = "commerce"; + public const string Platform = "platform"; + public const string Moderation = "moderation"; + public const string Web = "web"; + public const string Monolith = "monolith"; +} diff --git a/DorkNet.Gateway/DorkNet.Gateway.csproj b/DorkNet.Gateway/DorkNet.Gateway.csproj new file mode 100644 index 0000000..358445e --- /dev/null +++ b/DorkNet.Gateway/DorkNet.Gateway.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/DorkNet.Gateway/Program.cs b/DorkNet.Gateway/Program.cs new file mode 100644 index 0000000..d4b24f1 --- /dev/null +++ b/DorkNet.Gateway/Program.cs @@ -0,0 +1,351 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using Microsoft.Extensions.Options; +using Yarp.ReverseProxy.Configuration; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Gateway); +builder.Services.Configure( + builder.Configuration.GetSection("DorkNet:Services")); +var serviceMap = builder.Configuration + .GetSection("DorkNet:Services") + .Get() ?? new DorkNetServiceMapOptions(); +var apexDomain = builder.Configuration["Domain:Apex"] + ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN") + ?? "localhost"; +builder.Services + .AddReverseProxy() + .LoadFromMemory( + BuildRoutes(apexDomain, serviceMap), + BuildClusters(serviceMap)); + +var app = builder.Build(); + +app.MapDorkNetServiceDefaults(); + +app.MapGet("/internal/services", (IOptions options) => + Results.Ok(options.Value.Endpoints())); +app.MapGet("/internal/routes", () => + Results.Ok(new + { + ApexDomain = apexDomain, + Routes = BuildRoutes(apexDomain, serviceMap).Select(r => new + { + r.RouteId, + r.ClusterId, + r.Match.Hosts, + r.Match.Path, + r.Order, + }), + })); + +app.MapGet("/internal/services/health", async ( + IOptions options, + IHttpClientFactory httpClientFactory, + CancellationToken ct) => +{ + var client = httpClientFactory.CreateClient(); + var probes = new List(); + + foreach (var endpoint in options.Value.Endpoints()) + { + probes.Add(await ProbeServiceAsync(client, endpoint, ct)); + } + + return Results.Ok(probes); +}); + +app.MapReverseProxy(); + +app.Run(); + +static async Task ProbeServiceAsync( + HttpClient client, + DorkNetServiceEndpoint endpoint, + CancellationToken ct) +{ + var checkedAt = DateTimeOffset.UtcNow; + var baseUrl = endpoint.BaseUrl.TrimEnd('/'); + + try + { + using var response = await client.GetAsync($"{baseUrl}/internal/healthz", ct); + return new ServiceProbeResponse( + endpoint.Name, + endpoint.BaseUrl, + response.IsSuccessStatusCode ? "ok" : "unhealthy", + (int)response.StatusCode, + null, + checkedAt); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return new ServiceProbeResponse( + endpoint.Name, + endpoint.BaseUrl, + "unreachable", + null, + ex.Message, + checkedAt); + } +} + +static IReadOnlyList BuildRoutes( + string apexDomain, + DorkNetServiceMapOptions services) +{ + var routes = new List(); + var order = 0; + var webGroup = DorkNetRouteOwnership.RouteGroups + .First(group => group.ServiceName == ServiceNames.Web); + + AddHostRoute( + routes, + ref order, + webGroup.ServiceName, + BaseUrlFor(services, webGroup.ServiceName), + "web-admin-host", + [SubdomainHost("admin", apexDomain)]); + + AddHostRoute( + routes, + ref order, + webGroup.ServiceName, + BaseUrlFor(services, webGroup.ServiceName), + "web-feed-host", + [SubdomainHost("feed", apexDomain)]); + + AddHostPathRoutes( + routes, + ref order, + webGroup.ServiceName, + BaseUrlFor(services, webGroup.ServiceName), + WebBrowserHosts(apexDomain), + [ + "/admin", + "/assets", + "/brand", + "/feed", + "/join", + "/p", + "/photo", + "/players", + "/rooms", + "/u", + ]); + + foreach (var group in DorkNetRouteOwnership.RouteGroups.Where(group => group.ServiceName != ServiceNames.Web)) + { + AddHostRoute( + routes, + ref order, + group.ServiceName, + BaseUrlFor(services, group.ServiceName), + $"{group.ServiceName}-hosts", + group.HostSubdomains.Select(subdomain => SubdomainHost(subdomain, apexDomain)).ToArray()); + } + + foreach (var group in DorkNetRouteOwnership.RouteGroups) + { + AddPathRoutes( + routes, + ref order, + group.ServiceName, + BaseUrlFor(services, group.ServiceName), + group.PathPrefixes); + } + + AddHostRoute( + routes, + ref order, + webGroup.ServiceName, + BaseUrlFor(services, webGroup.ServiceName), + "web-hosts", + BuildWebHosts(webGroup, apexDomain)); + + if (!string.IsNullOrWhiteSpace(services.Monolith)) + { + routes.Add(Route(ServiceNames.Monolith, "monolith-fallback", "/{**catch-all}", order++)); + } + + return routes; +} + +static IReadOnlyList BuildClusters(DorkNetServiceMapOptions services) +{ + var clusters = new List(); + foreach (var endpoint in services.Endpoints()) + { + AddCluster(clusters, endpoint.Name, endpoint.BaseUrl); + } + + return clusters; +} + +static string[] WebBrowserHosts(string apexDomain) +{ + return [apexDomain, SubdomainHost("www", apexDomain)]; +} + +static void AddHostRoute( + List routes, + ref int order, + string serviceName, + string baseUrl, + string routeId, + string[] hosts) +{ + if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0) + { + return; + } + + routes.Add(Route( + serviceName, + routeId, + "/{**catch-all}", + order++, + hosts)); +} + +static void AddPathRoutes( + List routes, + ref int order, + string serviceName, + string baseUrl, + IEnumerable paths) +{ + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return; + } + + foreach (var path in paths) + { + var routeId = $"{serviceName}-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}"; + var exactPath = NormalizeRoutePrefix(path); + routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++)); + routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++)); + } +} + +static void AddHostPathRoutes( + List routes, + ref int order, + string serviceName, + string baseUrl, + string[] hosts, + IEnumerable paths) +{ + if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0) + { + return; + } + + foreach (var path in paths) + { + var routeId = $"{serviceName}-browser-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}"; + var exactPath = NormalizeRoutePrefix(path); + routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++, hosts)); + routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++, hosts)); + } +} + +static string BaseUrlFor(DorkNetServiceMapOptions services, string serviceName) +{ + return serviceName switch + { + ServiceNames.Identity => services.Identity, + ServiceNames.Rooms => services.Rooms, + ServiceNames.Notify => services.Notify, + ServiceNames.Content => services.Content, + ServiceNames.Social => services.Social, + ServiceNames.Commerce => services.Commerce, + ServiceNames.Platform => services.Platform, + ServiceNames.Moderation => services.Moderation, + ServiceNames.Web => services.Web, + ServiceNames.Monolith => services.Monolith, + _ => string.Empty, + }; +} + +static string[] BuildWebHosts(DorkNetRouteGroup group, string apexDomain) +{ + var hosts = new List(); + if (group.IncludesApexHost) + { + hosts.Add(apexDomain); + } + + hosts.AddRange(group.HostSubdomains + .Where(subdomain => !string.Equals(subdomain, "admin", StringComparison.OrdinalIgnoreCase)) + .Select(subdomain => SubdomainHost(subdomain, apexDomain))); + + return hosts.ToArray(); +} + +static string SubdomainHost(string subdomain, string apexDomain) +{ + return $"{subdomain}.{apexDomain}"; +} + +static string NormalizeRoutePrefix(string path) +{ + if (string.IsNullOrWhiteSpace(path) || path == "/") + { + return "/"; + } + + return (path[0] == '/' ? path : "/" + path).TrimEnd('/'); +} + +static RouteConfig Route( + string clusterId, + string routeId, + string path, + int order, + string[]? hosts = null) +{ + return new RouteConfig + { + RouteId = routeId, + ClusterId = clusterId, + Order = order, + Match = new RouteMatch + { + Hosts = hosts, + Path = path, + }, + Transforms = + [ + new Dictionary + { + ["RequestHeaderOriginalHost"] = "true", + }, + ], + }; +} + +static void AddCluster( + List clusters, + string serviceName, + string baseUrl) +{ + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return; + } + + var address = baseUrl.EndsWith('/') ? baseUrl : baseUrl + "/"; + clusters.Add(new ClusterConfig + { + ClusterId = serviceName, + Destinations = new Dictionary + { + ["primary"] = new() { Address = address }, + }, + }); +} diff --git a/DorkNet.Gateway/appsettings.json b/DorkNet.Gateway/appsettings.json new file mode 100644 index 0000000..e56c835 --- /dev/null +++ b/DorkNet.Gateway/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "System.Net.Http.HttpClient": "Warning" + } + }, + "DorkNet": { + "Services": { + "Identity": "http://localhost:8081", + "Rooms": "http://localhost:8082", + "Notify": "http://localhost:8083", + "Content": "http://localhost:8084", + "Social": "http://localhost:8085", + "Commerce": "http://localhost:8086", + "Platform": "http://localhost:8087", + "Moderation": "http://localhost:8088", + "Web": "http://localhost:8089", + "Monolith": "http://localhost:8090" + } + } +} diff --git a/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj new file mode 100644 index 0000000..7cd69a8 --- /dev/null +++ b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/DorkNet.Server.Tests/DorkNetServerFactory.cs b/DorkNet.Server.Tests/DorkNetServerFactory.cs new file mode 100644 index 0000000..58f73cf --- /dev/null +++ b/DorkNet.Server.Tests/DorkNetServerFactory.cs @@ -0,0 +1,90 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.HostFiltering; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace DorkNet.Server.Tests; + +public sealed class DorkNetServerFactory : WebApplicationFactory +{ + private const string JwtSecret = "endpoint-contract-secret-000000000000000000000000000000000000000000"; + private readonly string _dataRoot; + + public DorkNetServerFactory() + { + _dataRoot = Path.Combine(Path.GetTempPath(), "dorknet-server-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dataRoot); + } + + public string ApexDomain => "dork.test"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.UseSetting("Domain:Apex", ApexDomain); + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["AllowedHosts"] = "*", + ["Database:Provider"] = "sqlite", + ["Database:SqlitePath"] = Path.Combine(_dataRoot, "dorknet-test.db"), + ["Domain:Apex"] = ApexDomain, + ["Jwt:Secret"] = JwtSecret, + ["S3:Endpoint"] = "", + ["S3:AccessKey"] = "", + ["S3:SecretKey"] = "", + ["DorkNet:DefaultClientVersion"] = "december_2020_12_18", + ["DorkNet:SupportedVersions:0"] = "december_2020_12_18", + }); + }); + builder.ConfigureServices(services => + { + services.PostConfigure(options => + { + options.AllowedHosts = + [ + ApexDomain, + $"*.{ApexDomain}", + "localhost", + "127.0.0.1", + ]; + options.AllowEmptyHosts = true; + options.IncludeFailureMessage = true; + }); + services.PostConfigure(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, + }; + }); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + try + { + if (Directory.Exists(_dataRoot)) + { + Directory.Delete(_dataRoot, recursive: true); + } + } + catch + { + // Test cleanup must not hide the original test failure. + } + } +} diff --git a/DorkNet.Server.Tests/EndpointContractDiscovery.cs b/DorkNet.Server.Tests/EndpointContractDiscovery.cs new file mode 100644 index 0000000..abc75c9 --- /dev/null +++ b/DorkNet.Server.Tests/EndpointContractDiscovery.cs @@ -0,0 +1,222 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace DorkNet.Server.Tests; + +public static partial class EndpointContractDiscovery +{ + public static IReadOnlyList Discover() + { + var serverAssembly = typeof(Program).Assembly; + var controllerBaseType = typeof(ControllerBase); + return serverAssembly.GetTypes() + .Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t)) + .SelectMany(DiscoverControllerRoutes) + .OrderBy(route => route.Method, StringComparer.Ordinal) + .ThenBy(route => route.SamplePath, StringComparer.Ordinal) + .ThenBy(route => route.Source, StringComparer.Ordinal) + .ToArray(); + } + + private static IEnumerable DiscoverControllerRoutes(Type controller) + { + var controllerName = ControllerName(controller); + var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true) + .OfType() + .Select(a => a.Template) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Cast() + .DefaultIfEmpty("") + .ToArray(); + + foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)) + { + var httpAttrs = method.GetCustomAttributes(inherit: true) + .OfType() + .ToArray(); + if (httpAttrs.Length == 0) + { + continue; + } + + foreach (var attr in httpAttrs) + { + var attrMethods = attr.HttpMethods.Any() + ? attr.HttpMethods + : new[] { "ANY" }; + + foreach (var httpMethod in attrMethods) + { + foreach (var controllerTemplate in controllerRouteTemplates) + { + var template = CombineTemplates(controllerTemplate, attr.Template); + template = ReplaceRouteTokens(template, controllerName, method.Name); + yield return new EndpointContract( + httpMethod.ToUpperInvariant(), + template, + ToSamplePath(template), + $"{controller.FullName}.{method.Name}", + RequiresAuthorization(controller, method), + IsAdminEndpoint(controller, method), + ConsumesContentTypes(controller, method)); + } + } + } + } + } + + private static bool RequiresAuthorization(Type controller, MethodInfo method) + { + var attributes = controller.GetCustomAttributes(inherit: true) + .Concat(method.GetCustomAttributes(inherit: true)) + .ToArray(); + + if (attributes.OfType().Any()) + { + return false; + } + + return attributes.OfType().Any(); + } + + private static bool IsAdminEndpoint(Type controller, MethodInfo method) + { + return controller.FullName?.Contains(".Controllers.Admin.", StringComparison.Ordinal) == true + || method.Name.Contains("Admin", StringComparison.OrdinalIgnoreCase); + } + + private static string[] ConsumesContentTypes(Type controller, MethodInfo method) + { + var consumes = method.GetCustomAttributes(inherit: true) + .Concat(controller.GetCustomAttributes(inherit: true)) + .OfType() + .SelectMany(a => a.ContentTypes) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return consumes.Length == 0 + ? ["application/json"] + : consumes; + } + + private static string ControllerName(Type controller) + { + const string suffix = "Controller"; + return controller.Name.EndsWith(suffix, StringComparison.Ordinal) + ? controller.Name[..^suffix.Length] + : controller.Name; + } + + private static string CombineTemplates(string? controllerTemplate, string? actionTemplate) + { + controllerTemplate ??= ""; + actionTemplate ??= ""; + + if (actionTemplate.StartsWith("~/", StringComparison.Ordinal)) + { + return "/" + actionTemplate[2..].TrimStart('/'); + } + + if (actionTemplate.StartsWith("/", StringComparison.Ordinal)) + { + return "/" + actionTemplate.TrimStart('/'); + } + + var left = controllerTemplate.Trim('/'); + var right = actionTemplate.Trim('/'); + + if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right)) + { + return "/"; + } + + if (string.IsNullOrEmpty(left)) + { + return "/" + right; + } + + if (string.IsNullOrEmpty(right)) + { + return "/" + left; + } + + return "/" + left + "/" + right; + } + + private static string ReplaceRouteTokens(string template, string controllerName, string actionName) + { + return template + .Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase) + .Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase); + } + + private static string ToSamplePath(string template) + { + var path = RouteParameterRegex().Replace(template, match => + { + var name = match.Groups["name"].Value.ToLowerInvariant(); + var constraint = match.Groups["constraint"].Value.ToLowerInvariant(); + + if (name.Contains("playerid") || name.Contains("accountid") || name == "id") + { + return EndpointContract.TestPlayerToken; + } + + if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id")) + { + return "1"; + } + + if (constraint.Contains("bool")) + { + return "true"; + } + + if (constraint.Contains("guid")) + { + return "00000000-0000-0000-0000-000000000001"; + } + + if (name.Contains("roomname")) + { + return "DormRoom"; + } + + if (name.Contains("locale")) + { + return "en"; + } + + return "sample"; + }); + + path = Regex.Replace(path, "/+", "/"); + return path.StartsWith('/') ? path : "/" + path; + } + + [GeneratedRegex(@"\{[*]?(?[^}:=\?]+)(?:[^}=\?]+)?(?\?)?(?=[^}]+)?\}")] + private static partial Regex RouteParameterRegex(); +} + +public sealed record EndpointContract( + string Method, + string Template, + string SamplePath, + string Source, + bool RequiresAuthorization, + bool IsAdminEndpoint, + string[] Consumes) +{ + public const string TestPlayerToken = "__test_player_id__"; + + public string PathFor(GameClientSession session) + { + return SamplePath.Replace( + TestPlayerToken, + session.PlayerId.ToString(), + StringComparison.Ordinal); + } +} diff --git a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs new file mode 100644 index 0000000..2d7b860 --- /dev/null +++ b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs @@ -0,0 +1,411 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace DorkNet.Server.Tests; + +public sealed class GameClientEndpointContractTests : + IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public GameClientEndpointContractTests(DorkNetServerFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Game_client_contract_probe_reaches_every_controller_route() + { + var routes = EndpointContractDiscovery.Discover(); + Assert.NotEmpty(routes); + + using var client = _factory.CreateClient(new() + { + AllowAutoRedirect = false, + }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(20); + + var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain); + var failures = new List(); + + foreach (var route in routes) + { + var result = await ProbeAsync(client, route, session, _factory.ApexDomain); + if (!result.Passed) + { + failures.Add( + $"{route.Method,-6} {result.Host}{result.Path} {route.Source} expected {result.Expected}, got {result.Observed}" + + (string.IsNullOrWhiteSpace(result.ResponseExcerpt) ? "" : $" body={result.ResponseExcerpt}")); + } + } + + Assert.True( + failures.Count == 0, + $"Endpoint contract failures ({failures.Count}/{routes.Count}):{Environment.NewLine}" + + string.Join(Environment.NewLine, failures)); + } + + private static async Task ProbeAsync( + HttpClient client, + EndpointContract route, + GameClientSession session, + string apexDomain) + { + var path = route.PathFor(session); + path = AddClientQuery(path, route, session); + var host = HostFor(path, apexDomain); + using var request = new HttpRequestMessage( + ToHttpMethod(route.Method), + new Uri($"http://{host}{path}")); + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); + request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + + if (NeedsRequestBody(request.Method)) + { + request.Content = CreateContent(route, session); + } + + try + { + using var response = await client.SendAsync(request); + var responseText = await response.Content.ReadAsStringAsync(); + var statusCode = (int)response.StatusCode; + var passed = ResponseMatchesContract(route, response.StatusCode); + return new EndpointProbeResult( + route.Method, + host, + path, + ExpectedContract(route), + statusCode, + null, + Excerpt(responseText), + passed); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return new EndpointProbeResult( + route.Method, + host, + path, + ExpectedContract(route), + null, + ex.Message, + null, + false); + } + } + + private static bool ResponseMatchesContract( + EndpointContract route, + HttpStatusCode statusCode) + { + if ((int)statusCode >= 500) + { + return false; + } + + if (statusCode is HttpStatusCode.MethodNotAllowed + or HttpStatusCode.NotImplemented + or HttpStatusCode.UpgradeRequired) + { + return false; + } + + if (route.RequiresAuthorization + && statusCode is HttpStatusCode.Unauthorized) + { + return false; + } + + return true; + } + + private static string ExpectedContract(EndpointContract route) + { + if (route.IsAdminEndpoint) + { + return "admin endpoint may reject non-admin test player, but must not 5xx/405/426"; + } + + if (route.RequiresAuthorization) + { + return "authenticated test player should reach the action without 401/5xx/405/426; 403 is valid for owner/admin-only resources"; + } + + return "game-client request should not 5xx/405/426"; + } + + private static HttpMethod ToHttpMethod(string method) + { + return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase) + ? HttpMethod.Get + : new HttpMethod(method); + } + + private static bool NeedsRequestBody(HttpMethod method) + { + return method == HttpMethod.Post + || method == HttpMethod.Put + || method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase); + } + + private static HttpContent CreateContent( + EndpointContract route, + GameClientSession session) + { + var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray(); + if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded"))) + { + return new FormUrlEncodedContent(new Dictionary + { + ["account_id"] = session.PlayerId.ToString(), + ["AccountId"] = session.PlayerId.ToString(), + ["clubId"] = "1", + ["ClubId"] = "1", + ["description"] = "Endpoint contract description", + ["deviceId"] = session.DeviceId, + ["grant_type"] = "password", + ["image"] = "endpoint-contract.png", + ["imageName"] = "endpoint-contract.png", + ["fileName"] = "endpoint-contract.png", + ["message"] = "Endpoint contract message", + ["name"] = "EndpointContract", + ["newPassword"] = "endpoint-contract-password", + ["oldPassword"] = "", + ["password"] = "endpoint-contract-password", + ["ping"] = "42", + ["platformId"] = session.PlatformId, + ["playerId"] = session.PlayerId.ToString(), + ["playerIds"] = session.PlayerId.ToString(), + ["query"] = "DormRoom", + ["region"] = "us", + ["targetPlayerId"] = session.PlayerId.ToString(), + ["text"] = "Endpoint contract message", + ["username"] = "EndpointContract", + ["device_id"] = session.DeviceId, + ["platform"] = "0", + ["platform_id"] = session.PlatformId, + ["Ids"] = session.PlayerId.ToString(), + ["PlayerIds"] = session.PlayerId.ToString(), + }); + } + + if (consumes.Any(c => c.Contains("multipart/form-data"))) + { + var content = new MultipartFormDataContent(); + content.Add(new StringContent("endpoint-contract"), "name"); + content.Add(new StringContent(session.PlayerId.ToString()), "playerId"); + content.Add(new StringContent("endpoint-contract.png"), "imageName"); + content.Add(new StringContent("endpoint-contract.png"), "fileName"); + content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin"); + return content; + } + + if (consumes.Any(c => c.Contains("text/plain"))) + { + return new StringContent("", Encoding.UTF8, "text/plain"); + } + + var json = JsonSerializer.Serialize(new + { + PlayerId = session.PlayerId, + AccountId = session.PlayerId, + ClubId = 1, + Description = "Endpoint contract description", + DeviceId = session.DeviceId, + FileName = "endpoint-contract.png", + Id = session.PlayerId, + Image = "endpoint-contract.png", + ImageName = "endpoint-contract.png", + Message = "Endpoint contract message", + Platform = 0, + PlatformId = session.PlatformId, + PlayerIds = new[] { session.PlayerId }, + Name = "EndpointContract", + Password = "endpoint-contract-password", + Query = "DormRoom", + RoomName = "DormRoom", + TargetPlayerId = session.PlayerId, + Text = "Endpoint contract message", + }); + return new StringContent(json, Encoding.UTF8, "application/json"); + } + + private static string AddClientQuery( + string path, + EndpointContract route, + GameClientSession session) + { + var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + var lower = path.ToLowerInvariant(); + var query = new List(); + + if (lower.Contains("search", StringComparison.Ordinal) + || lower.Contains("query", StringComparison.Ordinal)) + { + query.Add("query=EndpointContract"); + } + + if (lower == "/members/bulk") + { + query.Add("clubId=1"); + query.Add($"playerIds={session.PlayerId}"); + } + + if (lower == "/player/photonregionpings") + { + query.Add("region=us"); + query.Add("ping=42"); + } + + if (route.Source.Contains("CachedLogins", StringComparison.Ordinal) + || lower.Contains("platformlogin/cached", StringComparison.Ordinal) + || lower.Contains("savedlogins", StringComparison.Ordinal)) + { + query.Add("platform=0"); + query.Add($"platformId={Uri.EscapeDataString(session.PlatformId)}"); + } + + if (route.Source.Contains("Bulk", StringComparison.Ordinal) + && !query.Any(q => q.StartsWith("Ids=", StringComparison.OrdinalIgnoreCase))) + { + query.Add($"Ids={session.PlayerId}"); + } + + if (query.Count == 0) + { + return path; + } + + return path + separator + string.Join("&", query); + } + + private static string HostFor(string path, string apexDomain) + { + var lower = path.ToLowerInvariant(); + + if (lower.StartsWith("/connect/") + || lower.StartsWith("/.well-known/") + || lower.StartsWith("/eac/") + || lower.StartsWith("/cachedlogin/") + || lower.StartsWith("/photon/") + || lower.StartsWith("/api/platformlogin/") + || lower.StartsWith("/api/version") + || lower.StartsWith("/api/auth/")) + { + return $"auth.{apexDomain}"; + } + + if (lower.StartsWith("/account/")) + { + return $"accounts.{apexDomain}"; + } + + if (lower.StartsWith("/roomserver/") + || lower.StartsWith("/rooms") + || lower.StartsWith("/playlists") + || lower.StartsWith("/featuredrooms") + || lower.StartsWith("/hot_rooms") + || lower.StartsWith("/search_rooms") + || lower.StartsWith("/api/rooms") + || lower.StartsWith("/api/roomkeys") + || lower.StartsWith("/api/gamesessions") + || lower.StartsWith("/api/curatedroomplaylists")) + { + return $"rooms.{apexDomain}"; + } + + if (lower.StartsWith("/v1/find") + || lower.StartsWith("/v1/join") + || lower.StartsWith("/v1/leave") + || lower.StartsWith("/v1/session") + || lower.StartsWith("/roominstance/") + || lower.StartsWith("/goto/") + || lower.StartsWith("/player/")) + { + return $"match.{apexDomain}"; + } + + if (lower.StartsWith("/thread")) + { + return $"chat.{apexDomain}"; + } + + if (lower.StartsWith("/club") + || lower.StartsWith("/announcements/") + || lower.StartsWith("/subscription/") + || lower.StartsWith("/members/") + || lower.StartsWith("/api/clubreporting/")) + { + return $"clubs.{apexDomain}"; + } + + if (lower.StartsWith("/api/storefronts/") + || lower.StartsWith("/api/purchase/") + || lower.StartsWith("/api/subscription/")) + { + return $"commerce.{apexDomain}"; + } + + if (lower.StartsWith("/hub/") || lower.StartsWith("/api/notification/")) + { + return $"notify.{apexDomain}"; + } + + if (lower.StartsWith("/v1/regions")) + { + return $"geo.{apexDomain}"; + } + + if (lower.StartsWith("/v1/services") || lower.StartsWith("/v1/ping")) + { + return $"ns.{apexDomain}"; + } + + if (lower.StartsWith("/upload")) + { + return $"storage.{apexDomain}"; + } + + if (lower.StartsWith("/img/") || lower.StartsWith("/room/") || lower.StartsWith("/data/")) + { + return $"cdn.{apexDomain}"; + } + + if (lower.StartsWith("/api/admin/")) + { + return $"admin.{apexDomain}"; + } + + return $"api.{apexDomain}"; + } + + private static string? Excerpt(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + var compact = body.ReplaceLineEndings(" ").Trim(); + return compact.Length <= 240 ? compact : compact[..240] + "..."; + } +} + +public sealed record EndpointProbeResult( + string Method, + string Host, + string Path, + string Expected, + int? StatusCode, + string? Error, + string? ResponseExcerpt, + bool Passed) +{ + public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response"; +} diff --git a/DorkNet.Server.Tests/GameClientSession.cs b/DorkNet.Server.Tests/GameClientSession.cs new file mode 100644 index 0000000..cfc519e --- /dev/null +++ b/DorkNet.Server.Tests/GameClientSession.cs @@ -0,0 +1,65 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace DorkNet.Server.Tests; + +public sealed record GameClientSession( + long PlayerId, + string AccessToken, + string DeviceId, + string PlatformId); + +public static class GameClientSessionFactory +{ + public static async Task CreateAsync( + HttpClient client, + string apexDomain) + { + var deviceId = $"endpoint-contract-device-{Guid.NewGuid():N}"; + var platformId = $"endpoint-contract-platform-{Guid.NewGuid():N}"; + var tokenRequest = new HttpRequestMessage( + HttpMethod.Post, + new Uri($"http://auth.{apexDomain}/connect/token")); + tokenRequest.Headers.Accept.ParseAdd("application/json"); + tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); + tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); + tokenRequest.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["username"] = "EndpointContract", + ["device_id"] = deviceId, + ["platform"] = "0", + ["platform_id"] = platformId, + }); + + using var tokenResponse = await client.SendAsync(tokenRequest); + if (!tokenResponse.IsSuccessStatusCode) + { + var body = await tokenResponse.Content.ReadAsStringAsync(); + throw new InvalidOperationException( + $"Test player token request failed with {(int)tokenResponse.StatusCode} {tokenResponse.StatusCode}: {body}"); + } + + using var tokenJson = await JsonDocument.ParseAsync( + await tokenResponse.Content.ReadAsStreamAsync()); + var accessToken = tokenJson.RootElement.GetProperty("access_token").GetString() + ?? throw new InvalidOperationException("Token response did not include access_token."); + + var userInfoRequest = new HttpRequestMessage( + HttpMethod.Get, + new Uri($"http://auth.{apexDomain}/connect/userinfo")); + userInfoRequest.Headers.Accept.ParseAdd("application/json"); + userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); + userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); + userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var userInfoResponse = await client.SendAsync(userInfoRequest); + userInfoResponse.EnsureSuccessStatusCode(); + + using var userInfoJson = await JsonDocument.ParseAsync( + await userInfoResponse.Content.ReadAsStreamAsync()); + var playerId = long.Parse(userInfoJson.RootElement.GetProperty("sub").GetString()!); + + return new GameClientSession(playerId, accessToken, deviceId, platformId); + } +} diff --git a/DorkNet.Server.Tests/VersionCheckControllerTests.cs b/DorkNet.Server.Tests/VersionCheckControllerTests.cs new file mode 100644 index 0000000..e63e2e0 --- /dev/null +++ b/DorkNet.Server.Tests/VersionCheckControllerTests.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using DorkNet.Server.Compat; +using DorkNet.Server.Controllers.Auth; +using DorkNet.Server.Versions.Late2020; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace DorkNet.Server.Tests; + +public sealed class VersionCheckControllerTests +{ + [Fact] + public void December_2020_build_is_valid_with_minimal_service_config() + { + var controller = CreateController(); + + var result = Assert.IsType(controller.Check("20201210", "0")); + + Assert.Equal(0, ReadVersionStatus(result.Value)); + } + + [Fact] + public void Unknown_build_still_requires_update() + { + var controller = CreateController(); + + var result = Assert.IsType(controller.Check("unknown", "0")); + + Assert.Equal(2, ReadVersionStatus(result.Value)); + } + + private static VersionCheckController CreateController() + { + var config = new ConfigurationBuilder().Build(); + var registry = new VersionRegistry( + [new Late2020VersionPlugin()], + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "december_2020_12_18", + }, + "december_2020_12_18"); + + return new VersionCheckController( + config, + registry, + NullLogger.Instance); + } + + private static int ReadVersionStatus(object? value) + { + var json = JsonSerializer.Serialize(value); + using var document = JsonDocument.Parse(json); + return document.RootElement.GetProperty("VersionStatus").GetInt32(); + } +} diff --git a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs index 62b036c..ca0d63d 100644 --- a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs +++ b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs @@ -201,12 +201,12 @@ public class AccountApiController( [HttpPost("api/account/v1/profileimage")] [HttpPost("api/account/me/profileimage")] - [HttpPost("account/me/profileimage")] - [HttpPost("profileimage")] + [HttpPost("account/me/profileimage", Order = 10)] + [HttpPost("profileimage", Order = 10)] [HttpPut("api/account/v1/profileimage")] [HttpPut("api/account/me/profileimage")] - [HttpPut("account/me/profileimage")] - [HttpPut("profileimage")] + [HttpPut("account/me/profileimage", Order = 10)] + [HttpPut("profileimage", Order = 10)] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task SetProfileImage( [FromForm(Name = "imageName")] string? imageName, diff --git a/DorkNet.Server/Controllers/API/Pageview/PageviewController.cs b/DorkNet.Server/Controllers/API/Pageview/PageviewController.cs index 198d831..b067fe1 100644 --- a/DorkNet.Server/Controllers/API/Pageview/PageviewController.cs +++ b/DorkNet.Server/Controllers/API/Pageview/PageviewController.cs @@ -41,9 +41,9 @@ public class PageviewController(ILogger logger) : Controller /// intentionally return an empty url + a 1-hour freshness — the /// private server has no in-app deep-link system, so there's /// nothing to navigate to and no reason to be polled more often. - [HttpPost("pageview/consume")] + [HttpPost("pageview/consume", Order = 10)] [HttpPost("/pageview/consume")] - [HttpGet("pageview/consume")] + [HttpGet("pageview/consume", Order = 10)] [HttpGet("/pageview/consume")] public IActionResult Consume() { diff --git a/DorkNet.Server/Controllers/Auth/AuthAccountController.cs b/DorkNet.Server/Controllers/Auth/AuthAccountController.cs index 767be6c..c2dc9ea 100644 --- a/DorkNet.Server/Controllers/Auth/AuthAccountController.cs +++ b/DorkNet.Server/Controllers/Auth/AuthAccountController.cs @@ -23,7 +23,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase /// ExpectPrimitiveResponse<bool> so the body must be /// the literal JSON true or false. [HttpGet("/account/me/haspassword")] - [HttpGet("/api/platformlogin/haspassword")] + [HttpGet("/api/platformlogin/haspassword", Order = 10)] [Authorize] public async Task HasPassword() { @@ -40,8 +40,8 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase /// one; first-time creation skips that check. Form-encoded body. [HttpPost("/account/me/changepassword")] [HttpPost("/account/me/createpassword")] - [HttpPost("/api/platformlogin/password")] - [HttpPut("/api/platformlogin/password")] + [HttpPost("/api/platformlogin/password", Order = 10)] + [HttpPut("/api/platformlogin/password", Order = 10)] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Authorize] public async Task ChangePassword( @@ -76,7 +76,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase /// account from the admin tab and the user re-creates it via /// changepassword on next login. Anonymous calls 401. [HttpPost("/account/me/resetpassword")] - [HttpPost("/api/platformlogin/resetpassword")] + [HttpPost("/api/platformlogin/resetpassword", Order = 10)] [Authorize] [AdminOnly] public async Task AdminResetPassword( diff --git a/DorkNet.Server/Controllers/Auth/VersionCheckController.cs b/DorkNet.Server/Controllers/Auth/VersionCheckController.cs index 30f310a..d44225e 100644 --- a/DorkNet.Server/Controllers/Auth/VersionCheckController.cs +++ b/DorkNet.Server/Controllers/Auth/VersionCheckController.cs @@ -46,6 +46,12 @@ public class VersionCheckController( private const int VersionStatus_ValidForMenu = 1; private const int VersionStatus_UpdateRequired = 2; + private static readonly IReadOnlyDictionary BuiltInBuildIdToVersionKey = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["20201210"] = "december_2020_12_18", + }; + [HttpGet("/api/versioncheck/v4")] [HttpGet("/api/versioncheck/v3")] [HttpGet("/api/versioncheck/v2")] @@ -66,9 +72,17 @@ public class VersionCheckController( // } // Mapping ABSENT → unknown build → UpdateRequired. var mapSection = config.GetSection("DorkNet:BuildIdToVersionKey"); - string? versionKey = !string.IsNullOrWhiteSpace(buildId) - ? mapSection[buildId] - : null; + string? versionKey = null; + if (!string.IsNullOrWhiteSpace(buildId)) + { + var normalizedBuildId = buildId.Trim(); + versionKey = mapSection[normalizedBuildId]; + if (string.IsNullOrWhiteSpace(versionKey) + && BuiltInBuildIdToVersionKey.TryGetValue(normalizedBuildId, out var builtInVersionKey)) + { + versionKey = builtInVersionKey; + } + } bool allowed = versionKey is not null && registry.IsSupported(versionKey); var status = allowed ? VersionStatus_ValidForPlay : VersionStatus_UpdateRequired; diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs index 1fe0536..bc93560 100644 --- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs +++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs @@ -668,11 +668,14 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/members/bulk")] public async Task MembersBulk() { + var form = Request.HasFormContentType ? Request.Form : null; if (!long.TryParse(Request.Query["clubId"].ToString(), out var clubId) - && !long.TryParse(Request.Form["clubId"].ToString(), out clubId)) + && (form is null || !long.TryParse(form["clubId"].ToString(), out clubId))) return BadRequest(new { error = "missing_club_id" }); - var idsRaw = Request.Query["playerIds"].Concat(Request.Form["playerIds"]); + var idsRaw = form is null + ? Request.Query["playerIds"].AsEnumerable() + : Request.Query["playerIds"].Concat(form["playerIds"]); var ids = idsRaw .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(s => long.TryParse(s, out var n) ? n : 0) diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 7f7f44c..d5252c6 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -220,9 +220,12 @@ public class MatchPlayerController( var playerId = TryGetCurrentPlayerId(); if (playerId == 0) return Ok(); - var form = Request.Form; - var regions = form["region"].ToArray(); - var pings = form["ping"].ToArray(); + var regions = Request.HasFormContentType + ? Request.Form["region"].ToArray() + : Request.Query["region"].ToArray(); + var pings = Request.HasFormContentType + ? Request.Form["ping"].ToArray() + : Request.Query["ping"].ToArray(); var pairs = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < Math.Min(regions.Length, pings.Length); i++) { diff --git a/DorkNet.Server/Data/MIGRATIONS.md b/DorkNet.Server/Data/MIGRATIONS.md index 8edf389..34d0104 100644 --- a/DorkNet.Server/Data/MIGRATIONS.md +++ b/DorkNet.Server/Data/MIGRATIONS.md @@ -6,8 +6,9 @@ (generated by `dotnet ef migrations add Initial` against the current entity model — captures all 67 `DbSet<>` tables). - **`LegacyUpgrades.cs`** holds idempotent data transforms that can't - be derived from the entity model. Runs once per server boot, after - EF migrations complete and before `RoomService.SeedAsync()`. + be derived from the entity model. Runs during host boot for the + standalone server and for service-slice containers, after schema + bootstrap completes and before `RoomService.SeedAsync()`. ## What changed in the consolidation @@ -29,7 +30,7 @@ Both were collapsed in 2026-05-25: no per-feature patches needed. - The PhotonRegion coerce (the only data transform in the block) was moved into `LegacyUpgrades.CoercePhotonRegionAsync`. -- The Storage admin page (`admin-ui/src/pages/Storage.tsx`) and +- The legacy storage backfill page (`admin-ui/src/pages/Storage.tsx`) and `StorageBackfillController.cs` were deleted — they were the UI for draining the legacy `RoomDataBlobs.Bytes` column into S3, which is no longer needed. @@ -37,8 +38,8 @@ Both were collapsed in 2026-05-25: ## Production cutover (one-time SQL) Existing prod databases need a one-time fixup to tell EF "the new -Initial is already applied here" so the next boot doesn't try to -`CREATE TABLE` over existing data. +Initial is already applied here" so the next standalone or service-slice +boot doesn't try to `CREATE TABLE` over existing data. ### Postgres path @@ -48,7 +49,7 @@ table exists** because EF Core only creates it when `Migrate()` runs. Postgres continues to use `EnsureCreated()` post-consolidation, so there's *nothing to do* — `EnsureCreated()` reads the entity model, sees the schema already matches, and no-ops. Just deploy the new -build. +standalone image or microservices compose stack. If you ever switch the Postgres path to `Migrate()`, run this once before the cutover deploy: diff --git a/DorkNet.Server/Data/url-coverage.md b/DorkNet.Server/Data/url-coverage.md index 07bb0e6..cc4e4f9 100644 --- a/DorkNet.Server/Data/url-coverage.md +++ b/DorkNet.Server/Data/url-coverage.md @@ -2,6 +2,13 @@ Generated: 2026-05-09. Source: client URLs from `Cpp2IL_ISIL` ISIL dump (curated list provided to this report); server routes harvested via grep of `[HttpGet]`/`[HttpPost]`/`[HttpPut]`/`[HttpDelete]`/`[Route]` attributes across `Controllers/**/*.cs` plus the namespace-wildcard fall-throughs in `ApiNamespaceStubsController` and the global `ApiCatchAllController` / `GlobalCatchAllController`. +> Snapshot scope: this file is a historical controller-coverage matrix, +> not the live gateway route table. The dedicated microservice hosts +> still reuse the shared controller stack behind route guards, so the +> endpoint shape notes remain useful. For current service ownership, use +> `DorkNet.Contracts/DorkNetRouteOwnership.cs` or the gateway +> `/internal/routes` endpoint. + ## How this matrix is built Each client URL is matched against the server's full route table. ASP.NET Core routes resolve "specific wins over wildcard", so a route literally registered as `api/foo/v1/bar` always wins over `api/foo/{*path}`. The `Status` column reflects the route that actually wins: diff --git a/DorkNet.Server/DorkNet.Server.csproj b/DorkNet.Server/DorkNet.Server.csproj index 8739260..f1b8c07 100644 --- a/DorkNet.Server/DorkNet.Server.csproj +++ b/DorkNet.Server/DorkNet.Server.csproj @@ -75,6 +75,15 @@ + + + + + +