From 86be2d004ff2d5ae6adda3e5dad824d8f448b375 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:10:16 +0300 Subject: [PATCH 01/15] Add env-driven Dokploy routing --- README.md | 40 +- docker-compose.microservices.dokploy.yml | 125 ++++++ docs/architecture.md | 58 ++- docs/deploy.md | 489 ++++++++++++----------- tools/cloudflared.README.md | 2 +- 5 files changed, 473 insertions(+), 241 deletions(-) create mode 100644 docker-compose.microservices.dokploy.yml diff --git a/README.md b/README.md index 240c8e7..d32a7ea 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ main also auto-detects your install. | **Version key** (sent as `X-DorkNet-Version` header) | `december_2020_12_18` | | **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` | | **Schema** | One EF `Initial` migration; SQLite + Postgres both work | +| **Runtime topology** | Full API still runs from `DorkNet.Server`; microservice gateway/service hosts are scaffolded for incremental split | | **Diverges from `march-2020-03-10` by** | ~170 files | Subsystems present on this branch that the March branch doesn't have: @@ -58,7 +59,7 @@ cd DorkNet | Config key | Env var | Required | Notes | |---|---|---|---| -| `Jwt:Secret` | `Jwt__Secret` | yes | ≥64 random chars | +| `Jwt:Secret` | `DORKNET_JWT_SECRET` or `Jwt__Secret` | yes | >=64 random chars | | `Photon:AppId` | `Photon__AppId` | yes | Photon Realtime AppId (free at dashboard.photonengine.com) | | `Photon:VoiceAppId` | `Photon__VoiceAppId` | no | only if voice chat | | `Photon:CloudRegion` | `Photon__CloudRegion` | no | defaults to `us` | @@ -66,6 +67,10 @@ cd DorkNet | `ConnectionStrings:Default` | `ConnectionStrings__Default` | postgres-only | full Npgsql connection string | | `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane | | `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production | +| `S3:Endpoint` | `S3__Endpoint` | production | S3-compatible API endpoint for blobs/images | +| `S3:AccessKey` | `S3__AccessKey` | production | S3 access key | +| `S3:SecretKey` | `S3__SecretKey` | production | S3 secret key | +| `S3:Region` | `S3__Region` | no | defaults to `garage`; use `auto` for R2 | **4. Boot:** @@ -79,8 +84,8 @@ categories / playlists, and starts listening. You should see `Application started.` in the log; `curl http://localhost:8080/healthz` returns 200. -For a full Docker / VPS deploy, see -[main's Advanced setup guide](../../blob/main/docs/advanced-setup.md). +For Docker, Dokploy, and microservices-scaffold deploy notes, see +[docs/deploy.md](docs/deploy.md). --- @@ -196,13 +201,42 @@ Default `0` (off). |---|---| | `DorkNet.Server/` | ASP.NET Core backend (controllers, services, data, hubs, middleware) | | `DorkNet.Models/` | DTOs shared between server and client mod | +| `DorkNet.Contracts/` | Shared service names, service-map options, and health/probe contracts | +| `DorkNet.ServiceDefaults/` | Shared microservice health, JSON, HTTP client, and service identity setup | +| `DorkNet.Gateway/` | Edge/gateway host scaffold with service-map and service-health endpoints | +| `DorkNet.Services.*` | Initial identity, rooms, and notify service host scaffolds | | `DorkNet.ClientMod/` | MelonLoader IL2CPP mod — the client patcher | | `tools/` | Installers (`install-melon.ps1`, `install-legacy-client.ps1`, `remove-eac.ps1`), Cloudflare tunnel templates | +| `Dockerfile`, `Dockerfile.service`, `docker-compose.microservices*.yml` | Full-server, local microservices, and Dokploy microservices deployment entrypoints | See [docs/architecture.md](docs/architecture.md) for the full mental model (project layout, request lifecycle, watch-mirror controller pattern, where to start contributing). +### Microservices scaffold + +`docker-compose.microservices.yml` starts the new gateway, identity, +rooms, and notify service hosts plus Compose-managed Postgres and Redis +for local testing. `docker-compose.microservices.dokploy.yml` is the +Dokploy version; it reads `DORKNET_DOMAIN` and publishes the required +Traefik host rules for the gateway. These files do **not** replace the +full `DorkNet.Server` runtime yet; the public API is still served by the +monolith while domains are peeled into services incrementally. + +The compose file intentionally does not start object storage. Point it at +your separate S3-compatible instance: + +```pwsh +$env:DORKNET_S3_ENDPOINT="https://your-s3-endpoint" +$env:DORKNET_S3_ACCESS_KEY="..." +$env:DORKNET_S3_SECRET_KEY="..." +$env:DORKNET_S3_REGION="garage" # or auto for R2 +docker compose -f docker-compose.microservices.yml up --build +``` + +For deployment details, including Dokploy env vars and the current +monolith-vs-microservices split, see [docs/deploy.md](docs/deploy.md). + --- ## Database & migrations diff --git a/docker-compose.microservices.dokploy.yml b/docker-compose.microservices.dokploy.yml new file mode 100644 index 0000000..3aee82a --- /dev/null +++ b/docker-compose.microservices.dokploy.yml @@ -0,0 +1,125 @@ +services: + gateway: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Gateway/DorkNet.Gateway.csproj + APP_DLL: DorkNet.Gateway.dll + environment: + DORKNET_DOMAIN: ${DORKNET_DOMAIN:?Set DORKNET_DOMAIN in Dokploy} + DorkNet__Services__Identity: http://identity:8080 + DorkNet__Services__Rooms: http://rooms:8080 + DorkNet__Services__Notify: http://notify:8080 + DorkNet__Services__Monolith: "" + expose: + - "8080" + labels: + traefik.enable: "true" + traefik.docker.network: dokploy-network + traefik.http.routers.dorknet-gateway.entrypoints: web + traefik.http.routers.dorknet-gateway.service: dorknet-gateway + traefik.http.routers.dorknet-gateway.rule: >- + Host(`${DORKNET_DOMAIN}`) + || Host(`www.${DORKNET_DOMAIN}`) + || Host(`admin.${DORKNET_DOMAIN}`) + || Host(`api.${DORKNET_DOMAIN}`) + || Host(`auth.${DORKNET_DOMAIN}`) + || Host(`accounts.${DORKNET_DOMAIN}`) + || Host(`bugreporting.${DORKNET_DOMAIN}`) + || Host(`cards.${DORKNET_DOMAIN}`) + || Host(`cdn.${DORKNET_DOMAIN}`) + || Host(`chat.${DORKNET_DOMAIN}`) + || Host(`clubs.${DORKNET_DOMAIN}`) + || Host(`cms.${DORKNET_DOMAIN}`) + || Host(`commerce.${DORKNET_DOMAIN}`) + || Host(`data.${DORKNET_DOMAIN}`) + || Host(`datacollection.${DORKNET_DOMAIN}`) + || Host(`discovery.${DORKNET_DOMAIN}`) + || Host(`econ.${DORKNET_DOMAIN}`) + || Host(`feed.${DORKNET_DOMAIN}`) + || Host(`gamelogs.${DORKNET_DOMAIN}`) + || Host(`geo.${DORKNET_DOMAIN}`) + || Host(`img.${DORKNET_DOMAIN}`) + || Host(`leaderboard.${DORKNET_DOMAIN}`) + || Host(`link.${DORKNET_DOMAIN}`) + || Host(`lists.${DORKNET_DOMAIN}`) + || Host(`match.${DORKNET_DOMAIN}`) + || Host(`moderation.${DORKNET_DOMAIN}`) + || Host(`notify.${DORKNET_DOMAIN}`) + || Host(`ns.${DORKNET_DOMAIN}`) + || Host(`platformnotifications.${DORKNET_DOMAIN}`) + || Host(`playersettings.${DORKNET_DOMAIN}`) + || Host(`roomcomments.${DORKNET_DOMAIN}`) + || Host(`roomieintegrations.${DORKNET_DOMAIN}`) + || Host(`rooms.${DORKNET_DOMAIN}`) + || Host(`storage.${DORKNET_DOMAIN}`) + || Host(`strings.${DORKNET_DOMAIN}`) + || Host(`strings-cdn.${DORKNET_DOMAIN}`) + || Host(`studio.${DORKNET_DOMAIN}`) + || Host(`thorn.${DORKNET_DOMAIN}`) + || Host(`videos.${DORKNET_DOMAIN}`) + traefik.http.services.dorknet-gateway.loadbalancer.server.port: "8080" + networks: + - default + - dokploy-network + depends_on: + - identity + - rooms + - notify + + identity: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Identity/DorkNet.Services.Identity.csproj + APP_DLL: DorkNet.Services.Identity.dll + depends_on: + - postgres + - redis + + rooms: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj + APP_DLL: DorkNet.Services.Rooms.dll + environment: + S3__Endpoint: ${DORKNET_S3_ENDPOINT:-} + S3__AccessKey: ${DORKNET_S3_ACCESS_KEY:-} + S3__SecretKey: ${DORKNET_S3_SECRET_KEY:-} + S3__Region: ${DORKNET_S3_REGION:-garage} + depends_on: + - postgres + - redis + + notify: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Notify/DorkNet.Services.Notify.csproj + APP_DLL: DorkNet.Services.Notify.dll + depends_on: + - redis + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: dorknet + POSTGRES_USER: dorknet + POSTGRES_PASSWORD: dorknet_dev + volumes: + - microservices-postgres:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + +networks: + dokploy-network: + external: true + +volumes: + microservices-postgres: diff --git a/docs/architecture.md b/docs/architecture.md index 8f58750..7a35c04 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,15 +14,27 @@ files in this folder. ``` DorkNet.sln -├── DorkNet.Server ASP.NET Core 9 backend — the bulk of the code -├── DorkNet.Models Shared DTO + serialization types -└── DorkNet.ClientMod MelonLoader 6 IL2CPP mod — the client patcher +├── DorkNet.Server ASP.NET Core 9 backend — full public API today +├── DorkNet.Models Shared DTO + serialization types +├── DorkNet.Contracts Shared service contracts for the service split +├── DorkNet.ServiceDefaults Shared service-host setup +├── DorkNet.Gateway Gateway/edge host scaffold +├── DorkNet.Services.Identity Identity service scaffold +├── DorkNet.Services.Rooms Rooms service scaffold +├── DorkNet.Services.Notify Notify service scaffold +└── DorkNet.ClientMod MelonLoader 6 IL2CPP mod — the client patcher ``` | Project | Responsibility | Target | |---|---|---| | `DorkNet.Server` | REST API, SignalR hub, admin SPA host, static-site host, image CDN, matchmaking, persistence | .NET 9 | | `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 9 | +| `DorkNet.Contracts` | Service names, service-map options, health responses, probe responses | .NET 9 | +| `DorkNet.ServiceDefaults` | Common microservice health checks, JSON defaults, HTTP clients, service identity | .NET 9 | +| `DorkNet.Gateway` | Edge/gateway scaffold; exposes internal service map and service health probes | .NET 9 | +| `DorkNet.Services.Identity` | Future home for accounts, auth, platform login, JWT issuance | .NET 9 | +| `DorkNet.Services.Rooms` | Future home for rooms, room keys, matchmaking, discovery | .NET 9 | +| `DorkNet.Services.Notify` | Future home for SignalR edge, notification fan-out, presence, request logs | .NET 9 | | `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 | `ClientMod` applies the client-side patches needed to point the 2020 @@ -48,9 +60,38 @@ DorkNet.Server/ ├── admin-ui/ Vite + React SPA source — builds to wwwroot/admin/ ├── site/ Public website Vite SPA source — builds to wwwroot/site/ ├── wwwroot/ Static file root, with per-host subdirs (admin/, site/, feed/) -└── Program.cs Host + DI + middleware pipeline (split candidates noted at the bottom) +├── Startup/ Host composition, DI registration, database bootstrap, middleware pipeline +└── Program.cs Short composition root ``` +## Runtime topology + +`DorkNet.Server` is still the only production-complete runtime. It owns +the full public Rec Room-compatible HTTP surface, SignalR hub, static +hosts, database bootstrap, Redis-backed ephemeral state, and S3-backed +blob storage. + +The microservice projects are the migration scaffold. They are wired into +the solution and Docker Compose, but they only expose internal health and +capability endpoints today: + +- `DorkNet.Gateway` exposes `/internal/services` and + `/internal/services/health`. +- `DorkNet.Services.Identity` exposes + `/internal/identity/capabilities`. +- `DorkNet.Services.Rooms` exposes `/internal/rooms/capabilities`. +- `DorkNet.Services.Notify` exposes `/internal/notify/capabilities`. + +`docker-compose.microservices.yml` starts gateway, identity, rooms, and +notify alongside Compose-managed Postgres and Redis for local testing. +`docker-compose.microservices.dokploy.yml` is the Dokploy shape: it puts +the gateway on `dokploy-network` and builds exact Traefik host rules from +`DORKNET_DOMAIN`. Object storage is external S3-compatible storage +configured with `DORKNET_S3_*` environment variables; the compose files +do not run MinIO or Garage. The next migration step is to move one public +route group at a time behind the gateway while preserving existing URLs +and response shapes. + ### Controllers/ groupings 20 top-level groups, each responsible for one product surface the @@ -236,11 +277,10 @@ worse. intentional — the decompiled source is provenance only, not shippable. Comments give a future contributor a breadcrumb to re-derive the wire shape if the decompile changes. -- **`Program.cs` is long.** Most of it is one-time schema patching - (`RunPatchAsync` calls for the Postgres `EnsureCreated` path) that's - pending consolidation per [`Data/MIGRATIONS.md`](../DorkNet.Server/Data/MIGRATIONS.md). - Once that lands, the remaining ~500 lines of middleware + host - setup will be split into `Startup/*` extensions. +- **Microservices are scaffolded, not fully cut over.** The gateway and + service hosts compile and run, but the public API still lives in + `DorkNet.Server`. Move endpoints by domain slice, not by copying random + controllers. - **Two-provider EF.** SQLite and Postgres each have their own EnsureCreated/Migrate dance because some Postgres-only column types drift the model snapshot. The split is annoying but lets one diff --git a/docs/deploy.md b/docs/deploy.md index 163aa5d..5ee8cf8 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,280 +1,313 @@ -# DorkNet — Coolify deployment +# DorkNet Deployment -This doc covers the production cutover from the local-machine setup -(SQLite + Kestrel direct + mkcert + per-machine cloudflared) to the -horizontally-scaled Coolify deployment (Postgres + Redis + Garage S3 + -2-replica Kestrel behind cloudflared, all on the same Coolify project). +This branch has two deployable shapes: -The plan-of-record is `BUILD_PROPER_PLAN.md` at the repo root and the -phased writeup at `~/.claude/plans/pahese-0-1-horizontal-cheeky-hickey.md`. -Anything that contradicts this doc, that doc wins. +- **Production/full API:** build the root `Dockerfile`, which runs + `DorkNet.Server`. This is still the runtime that serves every public + Rec Room-compatible endpoint. +- **Microservices scaffold:** deploy `docker-compose.microservices.yml` + for local testing or `docker-compose.microservices.dokploy.yml` on + Dokploy. Both start `DorkNet.Gateway`, + `DorkNet.Services.Identity`, `DorkNet.Services.Rooms`, + `DorkNet.Services.Notify`, Postgres, and Redis. These hosts currently + expose internal health/capability endpoints only; public routes move + behind the gateway incrementally. + +Object storage is always a **separate S3-compatible instance**. The +microservices compose files do not start MinIO/Garage. --- -## Coolify project layout +## Dokploy: full server -One Coolify *project* with these services: +Use this for a real server today. -| Service | Image / source | Notes | -| -------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | -| `dorknet-server` | Build from this repo's `Dockerfile` | Replicas: **2** to start. Internal port 8080. No public domain. | -| `dorknet-postgres` | Coolify one-click **Postgres** | Persistent volume on Coolify's data store. | -| `dorknet-redis` | Coolify one-click **Redis** | Persistent volume optional; used for ephemeral state + SignalR backplane. | -| `dorknet-garage` | Coolify one-click **Garage** | S3-compatible. Persistent volume (room blobs are 100s of MB). | -| `dorknet-cloudflared`| Cloudflare's `cloudflared` image, custom config | Reads `tools/cloudflared-config.coolify.yml.template` rendered with a tunnel ID. | +Create a Dokploy **Application**: -All five sit on the same project network so the server reaches its -dependencies over Coolify's internal Docker DNS. +| Field | Value | +|---|---| +| Source | This GitHub repo / branch | +| Build type | Dockerfile | +| Dockerfile path | `Dockerfile` | +| Context | `.` | +| Container port | `8080` | -## Required environment variables on `dorknet-server` +Set application environment variables: -Set these in Coolify's *Environment Variables* tab. Coolify expands -`${...}` references to other services in the same project, so the -connection-string lookups link automatically. - -``` +```env ASPNETCORE_ENVIRONMENT=Production +ASPNETCORE_URLS=http://0.0.0.0:8080 +DOTNET_RUNNING_IN_CONTAINER=true -# Database -ConnectionStrings__Default=Host=dorknet-postgres;Port=5432;Database=dorknet;Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD} +DORKNET_DOMAIN=yourdomain.com +DORKNET_JWT_SECRET=replace-with-at-least-64-random-characters -# Redis -ConnectionStrings__Redis=dorknet-redis:6379 +Database__Provider=postgres +ConnectionStrings__Default=Host=;Port=5432;Database=dorknet;Username=;Password= +ConnectionStrings__Redis=redis://:6379 -# JWT -DORKNET_JWT_SECRET= - -# Object storage (Garage). Bucket names are hard-coded in code; only -# the endpoint + credentials need configuring. Objects are organized -# under owner-scoped keys inside each bucket, e.g. -# players/{playerId}/camera, players/{playerId}/profile, -# players/{playerId}/rooms/{roomId}, players/{playerId}/dorm. -S3__Endpoint=http://dorknet-garage:3900 +S3__Endpoint=https://your-s3-api-endpoint +S3__AccessKey=your-access-key +S3__SecretKey=your-secret-key S3__Region=garage -S3__AccessKey= -S3__SecretKey= -# Image CDN signing. The 2020 client verifies Content-Signature on -# image downloads. Use the private half of the key whose public modulus -# the client knows for p1. Store PEM newlines as \n if your env UI is -# single-line, or use DORKNET_IMAGE_SIGNING_PRIVATE_KEY_BASE64. -DORKNET_IMAGE_SIGNING_KEY_ID=p1 -DORKNET_IMAGE_SIGNING_PRIVATE_KEY= - -# Photon (copy from local appsettings.json — same AppId across envs) -Photon__AppId= -Photon__VoiceAppId=1917ff4a-b7ad-4ad6-9ea2-9642bc174b70 -Photon__CloudRegion=us +Photon__AppId=your-photon-realtime-app-id +Photon__VoiceAppId=your-photon-voice-app-id +Photon__CloudRegion=eu ``` -### Cloudflare R2 instead of Garage - -DorkNet can use Cloudflare R2 without code changes because R2 speaks the -S3-compatible API. Create these three R2 buckets first: +If Postgres and Redis are Dokploy-managed services in the same project, +use the internal service hostnames Dokploy gives you. For a Compose-style +network the values usually look like: +```env +ConnectionStrings__Default=Host=postgres;Port=5432;Database=dorknet;Username=dorknet;Password=dorknet_dev +ConnectionStrings__Redis=redis://redis:6379 ``` + +### Domains + +Point DNS at the Dokploy server, then add domains to the same +application. Every host routes to container port `8080`. + +Add these hosts for full client compatibility: + +```text +yourdomain.com +www.yourdomain.com +admin.yourdomain.com +api.yourdomain.com +auth.yourdomain.com +accounts.yourdomain.com +bugreporting.yourdomain.com +cards.yourdomain.com +cdn.yourdomain.com +chat.yourdomain.com +clubs.yourdomain.com +cms.yourdomain.com +commerce.yourdomain.com +data.yourdomain.com +datacollection.yourdomain.com +discovery.yourdomain.com +econ.yourdomain.com +feed.yourdomain.com +gamelogs.yourdomain.com +geo.yourdomain.com +img.yourdomain.com +leaderboard.yourdomain.com +link.yourdomain.com +lists.yourdomain.com +match.yourdomain.com +moderation.yourdomain.com +notify.yourdomain.com +ns.yourdomain.com +platformnotifications.yourdomain.com +playersettings.yourdomain.com +roomcomments.yourdomain.com +roomieintegrations.yourdomain.com +rooms.yourdomain.com +storage.yourdomain.com +strings.yourdomain.com +strings-cdn.yourdomain.com +studio.yourdomain.com +thorn.yourdomain.com +videos.yourdomain.com +``` + +All of these point to the same `DorkNet.Server` application/container +port today. `admin` and `feed` serve static hosts; the rest are the +service URLs returned to the client by `ConfigService`. + +--- + +## Dokploy: microservices scaffold + +Use this to run the new gateway/service-host layout. It is not yet a +replacement for the full public API. + +Create a Dokploy **Compose** service from one of these files: + +| Compose file | Use | +|---|---| +| `docker-compose.microservices.yml` | Local/dev-style compose; use Dokploy's domain UI manually | +| `docker-compose.microservices.dokploy.yml` | Dokploy Traefik labels generated from `DORKNET_DOMAIN` | + +Both compose files provide: + +| Service | Purpose | +|---|---| +| `gateway` | Edge host scaffold; service map and service health probes | +| `identity` | Future identity/auth/accounts service | +| `rooms` | Future rooms/matchmaking/discovery service | +| `notify` | Future notification/presence service | +| `postgres` | Internal Postgres for the service network | +| `redis` | Internal Redis for ephemeral state and fan-out | + +Only the gateway needs a public domain during scaffold testing. Route the +gateway domain to service `gateway`, container port `8080`. + +In Dokploy's Compose domain modal: + +| Field | Value | +|---|---| +| Service Name | `gateway` | +| Host | the domain you are adding | +| Path | `/` | +| Internal Path | `/` | +| Container Port | `8080` | +| Strip Path | off | + +If the service dropdown says **Services not found**, deploy or redeploy +the Compose service first, then refresh the domain modal. Dokploy derives +the service list from the parsed compose file. + +### Env-driven domains + +Use `docker-compose.microservices.dokploy.yml` if you do not want to add +every domain row manually in Dokploy. Set: + +```env +DORKNET_DOMAIN=yourdomain.com +``` + +That file adds Traefik labels to `gateway` for the apex plus every host +listed in the full-server domain list above, then sends them all to +container port `8080`. It also attaches the gateway to Dokploy's external +`dokploy-network`, which is why this is a separate file instead of the +default local compose file. + +DNS still has to resolve to the Dokploy server. The simplest setup is an +apex record plus wildcard subdomain record: + +```text +yourdomain.com A/AAAA +*.yourdomain.com A/AAAA +``` + +If you do not use wildcard DNS, add A/AAAA or CNAME records for each +listed subdomain. + +When the gateway starts carrying the public API, every public DorkNet +domain listed above should point to `gateway`. Until then, keep the real +player-facing domains on the full `DorkNet.Server` application. + +Set these Compose environment variables in Dokploy: + +```env +DORKNET_S3_ENDPOINT=https://your-s3-api-endpoint +DORKNET_S3_ACCESS_KEY=your-access-key +DORKNET_S3_SECRET_KEY=your-secret-key +DORKNET_S3_REGION=garage +``` + +The compose services already refer to Postgres as `postgres` and Redis as +`redis` on the internal Compose network. Do not add an `object-storage` +service unless you intentionally want a local dev-only S3 emulator. + +Useful smoke checks after deploy: + +```bash +curl https://gateway.yourdomain.com/healthz +curl https://gateway.yourdomain.com/internal/services +curl https://gateway.yourdomain.com/internal/services/health +``` + +Expected during scaffold-only deploys: `/internal/services/health` +reports gateway-visible status for identity, rooms, and notify. It does +not prove the full game API has moved yet. + +--- + +## S3 storage + +DorkNet uses S3-compatible storage for room blobs, profile images, +camera photos, and CDN/image pipeline backing data. Create these buckets +before production cutover: + +```text profile-images camera-photos room-blobs ``` -Then create an R2 API token with Object Read & Write access to both -buckets and set the server env vars: +Garage-style endpoints usually use: +```env +S3__Endpoint=https://your-garage-or-s3-api +S3__Region=garage +S3__AccessKey=... +S3__SecretKey=... ``` + +Cloudflare R2 uses the S3 API endpoint, not a public bucket URL: + +```env S3__Endpoint=https://.r2.cloudflarestorage.com S3__Region=auto S3__AccessKey= S3__SecretKey= S3__MaxErrorRetry=1 -S3__TimeoutSeconds=8 +S3__TimeoutSeconds=300 ``` -Do not use an R2 public bucket URL or custom domain for `S3__Endpoint`. -That value must be the S3 API endpoint ending in -`.r2.cloudflarestorage.com`. -DorkNet disables S3 chunked upload encoding in code because R2 rejects -`STREAMING-AWS4-HMAC-SHA256-PAYLOAD` request bodies. +For the microservices compose file, use the `DORKNET_S3_*` equivalents; +Compose maps them into `S3__*` for the rooms service. -`appsettings.Production.json` already pins `Database:Provider=postgres` -and binds Kestrel to `http://0.0.0.0:8080`, so those don't need to be -set as env vars. +--- -## First-time cutover (stop-the-world) +## First-time cutover from SQLite -1. **Drain the local server.** Tell players, take it down. The cutover - is non-incremental — once the SQLite source is read, no further - writes can be accepted there. +1. Stop the local server so the SQLite source stops accepting writes. +2. Snapshot the SQLite database. +3. Provision Postgres, Redis, and the separate S3-compatible storage. +4. Run the migration/import tooling against the new Postgres and S3 + targets. +5. Deploy the full `DorkNet.Server` application. +6. Verify `/healthz` returns 200 before sending players to the new host. -2. **Snapshot SQLite.** `tools/snapshot-db.ps1` writes a timestamped - copy. Keep it indefinitely as the pre-cutover baseline. +The server uses an advisory-lock guarded startup path for Postgres so +multiple replicas can start without racing schema/bootstrap work. -3. **Deploy the Coolify project** with the four services above - (Postgres / Redis / Garage / cloudflared) but the server replicas - set to 0 — we want the database empty and idle while the migrator - runs. +Rollback before players write to Postgres is simple: stop the deploy and +return to the SQLite snapshot. After players write to Postgres, rollback +means losing post-cutover writes unless you migrate them back. -4. **Run the migrator** from the operator's laptop, pointed at the - newly-provisioned Postgres + Garage: +--- - ```pwsh - dotnet run --project Tools/MigrateSqliteToPostgres -- ` - --sqlite ./snapshots/dorknet-pre-cutover.db ` - --postgres "Host=;Port=5432;Database=dorknet;Username=...;Password=...;SSL Mode=Require" ` - --reset ` - --upload-blobs ` - --s3-endpoint http://:3900 ` - --s3-bucket room-blobs ` - --s3-access-key ... ` - --s3-secret-key ... - ``` +## Photon Cloud - The migrator runs the 9-step verification + an optional 10th step - that bulk-uploads every `RoomDataBlobs.Bytes` row to S3, idempotent - on re-run. A non-zero exit means **abort**: leave the destination - in its dirty state, fix the diff, re-run with `--reset` for a clean - slate. +Use your own Photon AppIds. Rec Room's original Photon AppIds reject +external clients because their dashboard points CustomAuth at the +official backend. -5. **Scale `dorknet-server` to 2 replicas.** First boot: one replica - wins the `pg_advisory_xact_lock` and runs `EnsureCreated` (no-op, - schema already there from the migrator) + the seed steps; the other - blocks on the lock until the first commits, sees the schema is - complete, and proceeds. +1. Create a Photon Realtime app at . +2. Create a Photon Voice app if voice chat is needed. +3. Set: -6. **Verify `/healthz` is 200** on both replicas (Coolify shows this - in the Container tab) before pointing the tunnel at the service. - -7. **Bring up `dorknet-cloudflared`** with the rendered config (see - `tools/cloudflared-config.coolify.yml.template`). Confirm `dig` / - `curl` against `https://api.localhost/api/health/v1` returns 200. - -8. **Tell players it's back.** Done. - -## Notes on multi-replica behaviour - -* **Sticky sessions are no longer required.** Auth's - `OrphanAccountTracker` writes the pending claim to Redis, so a - player whose two HTTP requests hit different replicas still sees - the same account-creation state. - -* **SignalR groups fan out across replicas.** The Redis backplane is - registered under the channel prefix `dorknet-signalr`; both replicas - publish/subscribe to the same channels, so a notification raised on - replica B reaches a player connected to replica A. - -* **Player presence is shared.** `PlayerPresenceService` writes - `presence:player:{id}` keys to Redis with a 45 s TTL (3× heartbeat). - Either replica can answer "where is player N?". - -* **Game session ids are allocated by Postgres.** `GameSessionService` - uses a `bigserial` so two replicas can `JoinOrCreate` simultaneously - without colliding. - -* **Private instances live in Postgres.** Cross-replica visibility is - automatic — both replicas read from the same `PrivateInstances` - table. - -## Rollback - -If the cutover goes sideways before step 5 (no replicas running yet), -just delete the Postgres / Redis / Garage volumes in Coolify, set the -server replica count back to 0, and bring the local SQLite server back -up. The pre-cutover snapshot is still untouched. - -After step 5, rolling back means **restoring the SQLite snapshot** -(any data players wrote on Postgres post-cutover will be lost). Be -sure the cutover validation in step 6 is convincing before you tell -players it's safe. - -## Photon Cloud — own AppId + CustomAuth callback - -The hard-coded RecRoom Photon AppId rejects external clients (NameServer -error code 32736 / "No auth request during expected wait time") because -their dashboard has CustomAuth set to "Reject if Auth Failed" pointing -at RecRoom's own backend. Solution: register your own free AppId. - -1. Go to https://dashboard.photonengine.com → sign up → **Create a New App** - → "Photon Realtime" → save. Repeat for "Photon Voice" if you want voice. -2. Copy each **AppId** (UUID) and set in Coolify env vars: - ``` - Photon__AppId= - Photon__VoiceAppId= - Photon__CloudRegion=us - ``` -3. Restart the server. - -That alone gets multiplayer working — the Photon NameServer accepts the -client because no CustomAuth is required. - -### (Optional) wire up server-side single-session enforcement - -Server already exposes `https://auth.localhost/photon/customauth` — see -[PhotonCustomAuthController.cs](DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs). -It accepts `userid` + `LoginLock` and validates them against -`PlayerPresenceService` (Redis). Enable it in the dashboard: - -1. Photon dashboard → your app → **Manage** → **Custom Authentication** tab. -2. **Enable Custom Authentication**. -3. URL: `https://auth.localhost/photon/customauth?userid={userid}&LoginLock={LoginLock}` -4. **Reject if Auth Failed**: **OFF for now** (see caveat below). -5. Save. - -**Caveat — current 2020 watch doesn't send AuthValues**. A sweep of -`Cpp2IL_ISIL/IsilDump/Assembly-CSharp/` turns up no caller of -`PhotonNetwork.set_AuthValues` outside Photon's own library code, so -the unmodified client connects without supplying `{userid, LoginLock}`. -With "Reject if Auth Failed" ON, every Photon connect would fail with -NameServer 32736 — same symptom we tried to solve. With it OFF, the -client connects fine, the endpoint exists for future client patching. - -### Patching the watch to send AuthValues — `DorkNet.ClientMod/` - -The vanilla 2020 watch never sets `PhotonNetwork.AuthValues` (verified -by an exhaustive grep across both `Assembly-CSharp.dll` and the -Photon `Assembly-CSharp-firstpass.dll` library). To make Photon -forward `userid` + `LoginLock` to our `/photon/customauth` endpoint a -Harmony patch on the watch's Photon auth path has to attach the values -in flight. That hook lives in the **MelonLoader IL2CPP mod** -(`DorkNet.ClientMod`); the AuthValues injector itself is currently -parked (see `DorkNet.ClientMod/attic/AuthValuesInjector.cs.attic` and -the notes in `Mod.cs`) since the `/photon/customauth` endpoint runs in -permissive mode. - -Install the mod on a tester's machine with: - -```pwsh -.\tools\install-melon.ps1 ` - -RecRoomPath "C:\…\RecRoom\Recroom_Release_Data" ` - -PhotonAppId cb0880d9-… +```env +Photon__AppId= +Photon__VoiceAppId= +Photon__CloudRegion=eu ``` -The script unpacks MelonLoader, builds `DorkNet.ClientMod.dll`, and -drops it into the client's `Mods/` folder. On the first run it prints -"launch the game once" so MelonLoader can generate its IL2CPP -assemblies; relaunch the script with `-ResumeBuild` to finish. +Server-side Photon custom auth exists at `/photon/customauth`, but the +2020 client does not send the needed AuthValues without client patching. +Leave Photon dashboard "Reject if Auth Failed" off unless the client mod +is updated to send `userid` and `LoginLock`. -Once AuthValues injection is re-enabled in the mod and in production: - -1. Flip [`PhotonCustomAuthController`](../DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs) - from permissive-mode (`ResultCode 1` always) back to strict — - parse `userid` + `LoginLock`, validate against - `PlayerPresenceService.ValidateLock`, return `ResultCode 3` on - mismatch. -2. In the Photon dashboard, flip **"Reject if Auth Failed"** ON. -3. Duplicate-account login from a different LoginLock now fails - Photon auth at the NameServer — never reaches the dorm. +--- ## Future schema changes -PR 4 keeps `EnsureCreated` for the first deploy (the migrator builds -the schema directly, then EnsureCreated is a no-op the server can do -under advisory-lock to be safe on multi-replica boot). Any -**post-cutover** schema change needs a real Postgres migration: +SQLite dev DBs apply EF migrations. The production Postgres path is still +based on `EnsureCreated()` plus idempotent bootstrap steps. Until that is +changed to `Migrate()` under the same advisory lock, treat production +schema changes as explicit deployment work: -1. `dotnet ef migrations add --project DorkNet.Server` -2. Replace the `EnsureCreated` call in `Program.cs` with - `db.Database.Migrate()` *inside* the same advisory-lock block. -3. Deploy. Both replicas race for the lock, the winner runs the - migration, the loser sees the new history row and skips. +1. Add or update the entity model. +2. Add the migration for SQLite/dev. +3. Add any needed idempotent Postgres bootstrap SQL. +4. Deploy and verify `/healthz`. -Until that swap happens, treat the schema as frozen. +See [`../DorkNet.Server/Data/MIGRATIONS.md`](../DorkNet.Server/Data/MIGRATIONS.md) +for the detailed migration discipline. diff --git a/tools/cloudflared.README.md b/tools/cloudflared.README.md index aa97e5e..de0ecfb 100644 --- a/tools/cloudflared.README.md +++ b/tools/cloudflared.README.md @@ -67,7 +67,7 @@ visitor → https://api.localhost/api/versioncheck/v4 → cloudflared tunnel → cloudflared (running on your machine) → https://localhost:443 (Host: api.localhost, mkcert cert) - → DorkNet.Server matches [Host("api.localhost")] → handler runs + → DorkNet.Server HostFiltering + subdomain-aware handlers ``` The leg from cloudflared → localhost has `noTLSVerify: true` because From bd4ceada657a95d15af457b9e115bb06f414d6d1 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:31:05 +0300 Subject: [PATCH 02/15] Use Cloudflare Tunnel for Dokploy routing --- README.md | 7 +-- docker-compose.microservices.dokploy.yml | 60 +++--------------------- docs/architecture.md | 14 +++--- docs/deploy.md | 58 +++++++++-------------- tools/cloudflared.README.md | 21 +++++++++ 5 files changed, 60 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index d32a7ea..0eb468f 100644 --- a/README.md +++ b/README.md @@ -218,9 +218,10 @@ pattern, where to start contributing). `docker-compose.microservices.yml` starts the new gateway, identity, rooms, and notify service hosts plus Compose-managed Postgres and Redis for local testing. `docker-compose.microservices.dokploy.yml` is the -Dokploy version; it reads `DORKNET_DOMAIN` and publishes the required -Traefik host rules for the gateway. These files do **not** replace the -full `DorkNet.Server` runtime yet; the public API is still served by the +Dokploy version; it adds a `cloudflared` sidecar so Cloudflare Tunnel +can route the apex and wildcard hostnames to the gateway without +Dokploy domain rows. These files do **not** replace the full +`DorkNet.Server` runtime yet; the public API is still served by the monolith while domains are peeled into services incrementally. The compose file intentionally does not start object storage. Point it at diff --git a/docker-compose.microservices.dokploy.yml b/docker-compose.microservices.dokploy.yml index 3aee82a..f3e0457 100644 --- a/docker-compose.microservices.dokploy.yml +++ b/docker-compose.microservices.dokploy.yml @@ -14,60 +14,18 @@ services: DorkNet__Services__Monolith: "" expose: - "8080" - labels: - traefik.enable: "true" - traefik.docker.network: dokploy-network - traefik.http.routers.dorknet-gateway.entrypoints: web - traefik.http.routers.dorknet-gateway.service: dorknet-gateway - traefik.http.routers.dorknet-gateway.rule: >- - Host(`${DORKNET_DOMAIN}`) - || Host(`www.${DORKNET_DOMAIN}`) - || Host(`admin.${DORKNET_DOMAIN}`) - || Host(`api.${DORKNET_DOMAIN}`) - || Host(`auth.${DORKNET_DOMAIN}`) - || Host(`accounts.${DORKNET_DOMAIN}`) - || Host(`bugreporting.${DORKNET_DOMAIN}`) - || Host(`cards.${DORKNET_DOMAIN}`) - || Host(`cdn.${DORKNET_DOMAIN}`) - || Host(`chat.${DORKNET_DOMAIN}`) - || Host(`clubs.${DORKNET_DOMAIN}`) - || Host(`cms.${DORKNET_DOMAIN}`) - || Host(`commerce.${DORKNET_DOMAIN}`) - || Host(`data.${DORKNET_DOMAIN}`) - || Host(`datacollection.${DORKNET_DOMAIN}`) - || Host(`discovery.${DORKNET_DOMAIN}`) - || Host(`econ.${DORKNET_DOMAIN}`) - || Host(`feed.${DORKNET_DOMAIN}`) - || Host(`gamelogs.${DORKNET_DOMAIN}`) - || Host(`geo.${DORKNET_DOMAIN}`) - || Host(`img.${DORKNET_DOMAIN}`) - || Host(`leaderboard.${DORKNET_DOMAIN}`) - || Host(`link.${DORKNET_DOMAIN}`) - || Host(`lists.${DORKNET_DOMAIN}`) - || Host(`match.${DORKNET_DOMAIN}`) - || Host(`moderation.${DORKNET_DOMAIN}`) - || Host(`notify.${DORKNET_DOMAIN}`) - || Host(`ns.${DORKNET_DOMAIN}`) - || Host(`platformnotifications.${DORKNET_DOMAIN}`) - || Host(`playersettings.${DORKNET_DOMAIN}`) - || Host(`roomcomments.${DORKNET_DOMAIN}`) - || Host(`roomieintegrations.${DORKNET_DOMAIN}`) - || Host(`rooms.${DORKNET_DOMAIN}`) - || Host(`storage.${DORKNET_DOMAIN}`) - || Host(`strings.${DORKNET_DOMAIN}`) - || Host(`strings-cdn.${DORKNET_DOMAIN}`) - || Host(`studio.${DORKNET_DOMAIN}`) - || Host(`thorn.${DORKNET_DOMAIN}`) - || Host(`videos.${DORKNET_DOMAIN}`) - traefik.http.services.dorknet-gateway.loadbalancer.server.port: "8080" - networks: - - default - - dokploy-network depends_on: - identity - rooms - notify + cloudflared: + image: cloudflare/cloudflared:latest + command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN:?Set CLOUDFLARE_TUNNEL_TOKEN in Dokploy} + depends_on: + - gateway + restart: unless-stopped + identity: build: context: . @@ -117,9 +75,5 @@ services: redis: image: redis:7-alpine -networks: - dokploy-network: - external: true - volumes: microservices-postgres: diff --git a/docs/architecture.md b/docs/architecture.md index 7a35c04..b8b2fe8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,13 +84,13 @@ capability endpoints today: `docker-compose.microservices.yml` starts gateway, identity, rooms, and notify alongside Compose-managed Postgres and Redis for local testing. -`docker-compose.microservices.dokploy.yml` is the Dokploy shape: it puts -the gateway on `dokploy-network` and builds exact Traefik host rules from -`DORKNET_DOMAIN`. Object storage is external S3-compatible storage -configured with `DORKNET_S3_*` environment variables; the compose files -do not run MinIO or Garage. The next migration step is to move one public -route group at a time behind the gateway while preserving existing URLs -and response shapes. +`docker-compose.microservices.dokploy.yml` is the Dokploy shape: it adds +a `cloudflared` sidecar that joins the same Compose network and forwards +Cloudflare Tunnel traffic to `http://gateway:8080`. Object storage is +external S3-compatible storage configured with `DORKNET_S3_*` +environment variables; the compose files do not run MinIO or Garage. The +next migration step is to move one public route group at a time behind +the gateway while preserving existing URLs and response shapes. ### Controllers/ groupings diff --git a/docs/deploy.md b/docs/deploy.md index 5ee8cf8..4d3aecb 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -129,8 +129,8 @@ Create a Dokploy **Compose** service from one of these files: | Compose file | Use | |---|---| -| `docker-compose.microservices.yml` | Local/dev-style compose; use Dokploy's domain UI manually | -| `docker-compose.microservices.dokploy.yml` | Dokploy Traefik labels generated from `DORKNET_DOMAIN` | +| `docker-compose.microservices.yml` | Local/dev-style compose | +| `docker-compose.microservices.dokploy.yml` | Dokploy compose with a Cloudflare Tunnel sidecar | Both compose files provide: @@ -142,50 +142,34 @@ Both compose files provide: | `notify` | Future notification/presence service | | `postgres` | Internal Postgres for the service network | | `redis` | Internal Redis for ephemeral state and fan-out | +| `cloudflared` | Cloudflare Tunnel sidecar; Dokploy file only | -Only the gateway needs a public domain during scaffold testing. Route the -gateway domain to service `gateway`, container port `8080`. +### Cloudflare Tunnel domains -In Dokploy's Compose domain modal: +Use `docker-compose.microservices.dokploy.yml` with Cloudflare Tunnel. +Do not add Dokploy domain rows for this compose stack; public traffic +enters through the `cloudflared` sidecar instead of Dokploy's Traefik +router. -| Field | Value | -|---|---| -| Service Name | `gateway` | -| Host | the domain you are adding | -| Path | `/` | -| Internal Path | `/` | -| Container Port | `8080` | -| Strip Path | off | - -If the service dropdown says **Services not found**, deploy or redeploy -the Compose service first, then refresh the domain modal. Dokploy derives -the service list from the parsed compose file. - -### Env-driven domains - -Use `docker-compose.microservices.dokploy.yml` if you do not want to add -every domain row manually in Dokploy. Set: +Set these Dokploy Compose environment variables: ```env DORKNET_DOMAIN=yourdomain.com +CLOUDFLARE_TUNNEL_TOKEN= ``` -That file adds Traefik labels to `gateway` for the apex plus every host -listed in the full-server domain list above, then sends them all to -container port `8080`. It also attaches the gateway to Dokploy's external -`dokploy-network`, which is why this is a separate file instead of the -default local compose file. - -DNS still has to resolve to the Dokploy server. The simplest setup is an -apex record plus wildcard subdomain record: +In Cloudflare Zero Trust, configure the tunnel with two public hostnames: ```text -yourdomain.com A/AAAA -*.yourdomain.com A/AAAA +yourdomain.com -> http://gateway:8080 +*.yourdomain.com -> http://gateway:8080 ``` -If you do not use wildcard DNS, add A/AAAA or CNAME records for each -listed subdomain. +The wildcard covers `api`, `auth`, `rooms`, `notify`, and the other +client-facing subdomains. The apex rule is separate because wildcard DNS +does not cover the root domain. Let Cloudflare create the tunnel DNS +records, or create CNAME records to the tunnel target Cloudflare gives +you. When the gateway starts carrying the public API, every public DorkNet domain listed above should point to `gateway`. Until then, keep the real @@ -207,9 +191,9 @@ service unless you intentionally want a local dev-only S3 emulator. Useful smoke checks after deploy: ```bash -curl https://gateway.yourdomain.com/healthz -curl https://gateway.yourdomain.com/internal/services -curl https://gateway.yourdomain.com/internal/services/health +curl https://api.yourdomain.com/healthz +curl https://api.yourdomain.com/internal/services +curl https://api.yourdomain.com/internal/services/health ``` Expected during scaffold-only deploys: `/internal/services/health` diff --git a/tools/cloudflared.README.md b/tools/cloudflared.README.md index de0ecfb..8f219c5 100644 --- a/tools/cloudflared.README.md +++ b/tools/cloudflared.README.md @@ -59,6 +59,27 @@ Or install as a Windows service so it survives reboots: cloudflared service install ``` +## Dokploy sidecar + +For Dokploy microservices, use +`docker-compose.microservices.dokploy.yml`. It starts `cloudflared` in +the same Compose network as `gateway`, so the tunnel origin is: + +```text +http://gateway:8080 +``` + +Set `CLOUDFLARE_TUNNEL_TOKEN` in Dokploy from the Cloudflare Zero Trust +tunnel token. In the Cloudflare tunnel's public hostnames, add: + +```text +yourdomain.com -> http://gateway:8080 +*.yourdomain.com -> http://gateway:8080 +``` + +Do not add these domains in Dokploy's domain modal for this compose +stack; Cloudflare owns the public hostname routing. + ## What happens at request time ``` From 44f53e80b8d451ce1054f452b0de03cb901c0414 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:21:05 +0300 Subject: [PATCH 03/15] Split remaining service routes --- .gitignore | 2 +- Dockerfile.service | 2 +- DorkNet.Contracts/DorkNetRouteOwnership.cs | 298 +++++++++++++ DorkNet.Contracts/DorkNetServiceMapOptions.cs | 14 +- DorkNet.Contracts/ServiceNames.cs | 6 + DorkNet.Gateway/DorkNet.Gateway.csproj | 4 + DorkNet.Gateway/Program.cs | 230 ++++++++++ DorkNet.Gateway/appsettings.json | 8 +- .../DorkNet.Server.Tests.csproj | 26 ++ DorkNet.Server.Tests/DorkNetServerFactory.cs | 90 ++++ .../EndpointContractDiscovery.cs | 222 ++++++++++ .../GameClientEndpointContractTests.cs | 411 ++++++++++++++++++ DorkNet.Server.Tests/GameClientSession.cs | 65 +++ .../API/Account/AccountApiController.cs | 8 +- .../API/Pageview/PageviewController.cs | 4 +- .../Controllers/Auth/AuthAccountController.cs | 8 +- .../Controllers/Clubs/ClubsController.cs | 7 +- .../Match/MatchPlayerController.cs | 9 +- DorkNet.Server/Program.cs | 2 + .../DorkNetServiceDefaultsExtensions.cs | 50 ++- .../DorkNet.Services.Commerce.csproj | 15 + DorkNet.Services.Commerce/Program.cs | 37 ++ DorkNet.Services.Commerce/appsettings.json | 8 + .../DorkNet.Services.Content.csproj | 15 + DorkNet.Services.Content/Program.cs | 37 ++ DorkNet.Services.Content/appsettings.json | 8 + .../DorkNet.Services.Identity.csproj | 1 + DorkNet.Services.Identity/Program.cs | 8 +- .../DorkNet.Services.Moderation.csproj | 15 + DorkNet.Services.Moderation/Program.cs | 37 ++ DorkNet.Services.Moderation/appsettings.json | 8 + .../DorkNet.Services.Notify.csproj | 1 + DorkNet.Services.Notify/Program.cs | 8 +- .../DorkNet.Services.Platform.csproj | 15 + DorkNet.Services.Platform/Program.cs | 37 ++ DorkNet.Services.Platform/appsettings.json | 8 + .../DorkNet.Services.Rooms.csproj | 1 + DorkNet.Services.Rooms/Program.cs | 8 +- .../DorkNet.Services.Social.csproj | 15 + DorkNet.Services.Social/Program.cs | 37 ++ DorkNet.Services.Social/appsettings.json | 8 + .../DorkNet.Services.Web.csproj | 15 + DorkNet.Services.Web/Program.cs | 36 ++ DorkNet.Services.Web/appsettings.json | 8 + DorkNet.sln | 117 ++++- README.md | 29 +- docker-compose.microservices.dokploy.yml | 126 +++++- docker-compose.microservices.yml | 127 +++++- docs/architecture.md | 78 ++-- docs/deploy.md | 59 +-- tools/cloudflared.README.md | 5 +- 51 files changed, 2271 insertions(+), 122 deletions(-) create mode 100644 DorkNet.Contracts/DorkNetRouteOwnership.cs create mode 100644 DorkNet.Server.Tests/DorkNet.Server.Tests.csproj create mode 100644 DorkNet.Server.Tests/DorkNetServerFactory.cs create mode 100644 DorkNet.Server.Tests/EndpointContractDiscovery.cs create mode 100644 DorkNet.Server.Tests/GameClientEndpointContractTests.cs create mode 100644 DorkNet.Server.Tests/GameClientSession.cs create mode 100644 DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj create mode 100644 DorkNet.Services.Commerce/Program.cs create mode 100644 DorkNet.Services.Commerce/appsettings.json create mode 100644 DorkNet.Services.Content/DorkNet.Services.Content.csproj create mode 100644 DorkNet.Services.Content/Program.cs create mode 100644 DorkNet.Services.Content/appsettings.json create mode 100644 DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj create mode 100644 DorkNet.Services.Moderation/Program.cs create mode 100644 DorkNet.Services.Moderation/appsettings.json create mode 100644 DorkNet.Services.Platform/DorkNet.Services.Platform.csproj create mode 100644 DorkNet.Services.Platform/Program.cs create mode 100644 DorkNet.Services.Platform/appsettings.json create mode 100644 DorkNet.Services.Social/DorkNet.Services.Social.csproj create mode 100644 DorkNet.Services.Social/Program.cs create mode 100644 DorkNet.Services.Social/appsettings.json create mode 100644 DorkNet.Services.Web/DorkNet.Services.Web.csproj create mode 100644 DorkNet.Services.Web/Program.cs create mode 100644 DorkNet.Services.Web/appsettings.json diff --git a/.gitignore b/.gitignore index 7ae432c..2aa3740 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 diff --git a/Dockerfile.service b/Dockerfile.service index a84bde7..cd9a6ee 100644 --- a/Dockerfile.service +++ b/Dockerfile.service @@ -5,7 +5,7 @@ ARG PROJECT_PATH WORKDIR /src COPY . . -RUN dotnet publish "${PROJECT_PATH}" -c Release -o /app/publish +RUN dotnet publish "${PROJECT_PATH}" -c Release -p:SkipAdminUIBuild=true -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime ARG APP_DLL 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 index cfb2244..bdc5119 100644 --- a/DorkNet.Contracts/DorkNetServiceMapOptions.cs +++ b/DorkNet.Contracts/DorkNetServiceMapOptions.cs @@ -5,7 +5,13 @@ 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 Monolith { get; set; } = "http://localhost:8084"; + 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() { @@ -14,6 +20,12 @@ public sealed class DorkNetServiceMapOptions 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), ]; diff --git a/DorkNet.Contracts/ServiceNames.cs b/DorkNet.Contracts/ServiceNames.cs index 12dae24..79f63ef 100644 --- a/DorkNet.Contracts/ServiceNames.cs +++ b/DorkNet.Contracts/ServiceNames.cs @@ -6,5 +6,11 @@ public static class ServiceNames 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 index 8e34531..358445e 100644 --- a/DorkNet.Gateway/DorkNet.Gateway.csproj +++ b/DorkNet.Gateway/DorkNet.Gateway.csproj @@ -6,6 +6,10 @@ enable + + + + diff --git a/DorkNet.Gateway/Program.cs b/DorkNet.Gateway/Program.cs index b0b2808..aa9e48c 100644 --- a/DorkNet.Gateway/Program.cs +++ b/DorkNet.Gateway/Program.cs @@ -1,6 +1,7 @@ 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", @@ -9,6 +10,17 @@ builder.Configuration.AddJsonFile("appsettings.Local.json", 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(); @@ -17,6 +29,19 @@ app.MapDorkNetServiceDefaults(); app.MapGet("/", () => Results.Redirect("/healthz")); 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, @@ -34,6 +59,8 @@ app.MapGet("/internal/services/health", async ( return Results.Ok(probes); }); +app.MapReverseProxy(); + app.Run(); static async Task ProbeServiceAsync( @@ -66,3 +93,206 @@ static async Task ProbeServiceAsync( 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)]); + + 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 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 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 index 3daa59e..e56c835 100644 --- a/DorkNet.Gateway/appsettings.json +++ b/DorkNet.Gateway/appsettings.json @@ -11,7 +11,13 @@ "Identity": "http://localhost:8081", "Rooms": "http://localhost:8082", "Notify": "http://localhost:8083", - "Monolith": "http://localhost:8084" + "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/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/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 b986621..883de03 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -223,9 +223,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/Program.cs b/DorkNet.Server/Program.cs index 2e528cc..601f981 100644 --- a/DorkNet.Server/Program.cs +++ b/DorkNet.Server/Program.cs @@ -15,3 +15,5 @@ var app = builder.Build(); await app.RunDatabaseBootstrapAsync(); app.UseDorkNetPipeline(); app.Run(); + +public partial class Program; diff --git a/DorkNet.ServiceDefaults/DorkNetServiceDefaultsExtensions.cs b/DorkNet.ServiceDefaults/DorkNetServiceDefaultsExtensions.cs index 19ae748..8de699d 100644 --- a/DorkNet.ServiceDefaults/DorkNetServiceDefaultsExtensions.cs +++ b/DorkNet.ServiceDefaults/DorkNetServiceDefaultsExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace DorkNet.ServiceDefaults; @@ -21,9 +22,15 @@ public static class DorkNetServiceDefaultsExtensions return builder; } - public static WebApplication MapDorkNetServiceDefaults(this WebApplication app) + public static WebApplication MapDorkNetServiceDefaults( + this WebApplication app, + bool mapPublicHealth = true) { - app.MapHealthChecks("/healthz"); + if (mapPublicHealth) + { + app.MapHealthChecks("/healthz"); + } + app.MapGet("/internal/healthz", (DorkNetServiceInfo info) => { return Results.Ok(new ServiceHealthResponse( @@ -34,4 +41,43 @@ public static class DorkNetServiceDefaultsExtensions return app; } + + public static WebApplication UseDorkNetRouteOwnershipGuard( + this WebApplication app, + string serviceName) + { + app.Use(async (ctx, next) => + { + var domain = Environment.GetEnvironmentVariable("DORKNET_DOMAIN") + ?? ctx.RequestServices.GetService()?["Domain:Apex"] + ?? "localhost"; + var path = ctx.Request.Path.Value ?? "/"; + if (DorkNetRouteOwnership.IsOwnedBy(serviceName, ctx.Request.Host.Host, path, domain)) + { + await next(); + return; + } + + var owner = DorkNetRouteOwnership.ResolvePublicService(ctx.Request.Host.Host, path, domain); + var logger = ctx.RequestServices + .GetRequiredService() + .CreateLogger("DorkNet.ServiceDefaults.RouteGuard"); + logger.LogDebug( + "[route-guard] service={Service} rejected {Method} {Host}{Path}; owner={Owner}", + serviceName, + ctx.Request.Method, + ctx.Request.Host, + ctx.Request.Path, + owner); + ctx.Response.StatusCode = StatusCodes.Status404NotFound; + await ctx.Response.WriteAsJsonAsync(new + { + error = "route_not_owned_by_service", + service = serviceName, + owner, + }); + }); + + return app; + } } diff --git a/DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj b/DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Commerce/Program.cs b/DorkNet.Services.Commerce/Program.cs new file mode 100644 index 0000000..5157675 --- /dev/null +++ b/DorkNet.Services.Commerce/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Commerce); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/commerce/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Commerce, + [ + "catalog", + "storefronts", + "econ", + "inventory", + "inventions", + ], + [ + "/api/catalog/*", + "/api/storefronts/*", + "/econ/*", + "/api/equipment/*", + "/api/inventions/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Commerce); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Commerce/appsettings.json b/DorkNet.Services.Commerce/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Commerce/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Content/DorkNet.Services.Content.csproj b/DorkNet.Services.Content/DorkNet.Services.Content.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Content/DorkNet.Services.Content.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Content/Program.cs b/DorkNet.Services.Content/Program.cs new file mode 100644 index 0000000..d7c091f --- /dev/null +++ b/DorkNet.Services.Content/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Content); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/content/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Content, + [ + "cdn", + "images", + "photos", + "room-blobs", + "storage", + ], + [ + "/api/images/*", + "/api/photos/*", + "/data/*", + "/img/*", + "/upload", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Content); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Content/appsettings.json b/DorkNet.Services.Content/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Content/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Identity/DorkNet.Services.Identity.csproj b/DorkNet.Services.Identity/DorkNet.Services.Identity.csproj index 8e34531..6ddeec4 100644 --- a/DorkNet.Services.Identity/DorkNet.Services.Identity.csproj +++ b/DorkNet.Services.Identity/DorkNet.Services.Identity.csproj @@ -9,6 +9,7 @@ + diff --git a/DorkNet.Services.Identity/Program.cs b/DorkNet.Services.Identity/Program.cs index 1697272..a111de9 100644 --- a/DorkNet.Services.Identity/Program.cs +++ b/DorkNet.Services.Identity/Program.cs @@ -1,15 +1,18 @@ using DorkNet.Contracts; using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); builder.AddDorkNetServiceDefaults(ServiceNames.Identity); +builder.AddDorkNetServices(); var app = builder.Build(); -app.MapDorkNetServiceDefaults(); +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); app.MapGet("/internal/identity/capabilities", () => Results.Ok(new ServiceCapabilityResponse( ServiceNames.Identity, @@ -26,4 +29,7 @@ app.MapGet("/internal/identity/capabilities", () => "/api/auth/*", ]))); +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Identity); +app.UseDorkNetPipeline(); + app.Run(); diff --git a/DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj b/DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Moderation/Program.cs b/DorkNet.Services.Moderation/Program.cs new file mode 100644 index 0000000..954290e --- /dev/null +++ b/DorkNet.Services.Moderation/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Moderation); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/moderation/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Moderation, + [ + "bug-reporting", + "player-reporting", + "sanitize", + "admin-api", + "testcase-management", + ], + [ + "/api/bugreporting/*", + "/api/playerreporting/*", + "/api/sanitize/*", + "/api/admin/*", + "/api/testcasemanagement/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Moderation); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Moderation/appsettings.json b/DorkNet.Services.Moderation/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Moderation/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Notify/DorkNet.Services.Notify.csproj b/DorkNet.Services.Notify/DorkNet.Services.Notify.csproj index 8e34531..6ddeec4 100644 --- a/DorkNet.Services.Notify/DorkNet.Services.Notify.csproj +++ b/DorkNet.Services.Notify/DorkNet.Services.Notify.csproj @@ -9,6 +9,7 @@ + diff --git a/DorkNet.Services.Notify/Program.cs b/DorkNet.Services.Notify/Program.cs index 4e2f842..5500162 100644 --- a/DorkNet.Services.Notify/Program.cs +++ b/DorkNet.Services.Notify/Program.cs @@ -1,15 +1,18 @@ using DorkNet.Contracts; using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); builder.AddDorkNetServiceDefaults(ServiceNames.Notify); +builder.AddDorkNetServices(); var app = builder.Build(); -app.MapDorkNetServiceDefaults(); +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); app.MapGet("/internal/notify/capabilities", () => Results.Ok(new ServiceCapabilityResponse( ServiceNames.Notify, @@ -25,4 +28,7 @@ app.MapGet("/internal/notify/capabilities", () => "/player/*presence*", ]))); +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Notify); +app.UseDorkNetPipeline(); + app.Run(); diff --git a/DorkNet.Services.Platform/DorkNet.Services.Platform.csproj b/DorkNet.Services.Platform/DorkNet.Services.Platform.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Platform/DorkNet.Services.Platform.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Platform/Program.cs b/DorkNet.Services.Platform/Program.cs new file mode 100644 index 0000000..fe5e38e --- /dev/null +++ b/DorkNet.Services.Platform/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Platform); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/platform/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Platform, + [ + "service-directory", + "config", + "version-check", + "geo", + "strings", + ], + [ + "/v1/services", + "/api/config/*", + "/api/versioncheck/*", + "/v1/regions", + "/strings/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Platform); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Platform/appsettings.json b/DorkNet.Services.Platform/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Platform/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj b/DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj index 8e34531..6ddeec4 100644 --- a/DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj +++ b/DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj @@ -9,6 +9,7 @@ + diff --git a/DorkNet.Services.Rooms/Program.cs b/DorkNet.Services.Rooms/Program.cs index ed11f71..1990c87 100644 --- a/DorkNet.Services.Rooms/Program.cs +++ b/DorkNet.Services.Rooms/Program.cs @@ -1,15 +1,18 @@ using DorkNet.Contracts; using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); builder.AddDorkNetServiceDefaults(ServiceNames.Rooms); +builder.AddDorkNetServices(); var app = builder.Build(); -app.MapDorkNetServiceDefaults(); +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); app.MapGet("/internal/rooms/capabilities", () => Results.Ok(new ServiceCapabilityResponse( ServiceNames.Rooms, @@ -28,4 +31,7 @@ app.MapGet("/internal/rooms/capabilities", () => "/v1/join/*", ]))); +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Rooms); +app.UseDorkNetPipeline(); + app.Run(); diff --git a/DorkNet.Services.Social/DorkNet.Services.Social.csproj b/DorkNet.Services.Social/DorkNet.Services.Social.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Social/DorkNet.Services.Social.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Social/Program.cs b/DorkNet.Services.Social/Program.cs new file mode 100644 index 0000000..5b4598e --- /dev/null +++ b/DorkNet.Services.Social/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Social); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/social/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Social, + [ + "clubs", + "groups", + "announcements", + "player-events", + "subscriptions", + ], + [ + "/club/*", + "/api/groups/*", + "/api/playerevents/*", + "/api/playersubscriptions/*", + "/announcements/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Social); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Social/appsettings.json b/DorkNet.Services.Social/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Social/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Web/DorkNet.Services.Web.csproj b/DorkNet.Services.Web/DorkNet.Services.Web.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Web/DorkNet.Services.Web.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Web/Program.cs b/DorkNet.Services.Web/Program.cs new file mode 100644 index 0000000..08b83d6 --- /dev/null +++ b/DorkNet.Services.Web/Program.cs @@ -0,0 +1,36 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Web); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/web/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Web, + [ + "public-site", + "admin-site", + "feed-site", + "site-api", + ], + [ + "/", + "www.*", + "admin.*", + "feed.*", + "/api/site/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Web); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Web/appsettings.json b/DorkNet.Services.Web/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Web/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.sln b/DorkNet.sln index dfcd048..0aeafdd 100644 --- a/DorkNet.sln +++ b/DorkNet.sln @@ -7,10 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Server", "DorkNet.S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Models", "DorkNet.Models\DorkNet.Models.csproj", "{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", "Tools\MigrateSqliteToPostgres\MigrateSqliteToPostgres.csproj", "{70C60098-1B11-4C13-943B-A8A056429EAE}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.AdminMobile", "DorkNet.AdminMobile\DorkNet.AdminMobile.csproj", "{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Contracts", "DorkNet.Contracts\DorkNet.Contracts.csproj", "{7D9E995E-5333-43B2-848B-3522C6C3D893}" @@ -25,6 +21,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Rooms", "D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Notify", "DorkNet.Services.Notify\DorkNet.Services.Notify.csproj", "{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Server.Tests", "DorkNet.Server.Tests\DorkNet.Server.Tests.csproj", "{43230177-A02F-440A-9B59-B79AB5E7BC27}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Content", "DorkNet.Services.Content\DorkNet.Services.Content.csproj", "{8958DA76-3925-4F12-8884-DE2E79FE9677}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Social", "DorkNet.Services.Social\DorkNet.Services.Social.csproj", "{C6DBC037-1E6B-4B2F-8490-08A6F9380E00}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Commerce", "DorkNet.Services.Commerce\DorkNet.Services.Commerce.csproj", "{77BBD5A3-A610-4A20-A61A-B4AB56291C27}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Platform", "DorkNet.Services.Platform\DorkNet.Services.Platform.csproj", "{C68AA2CA-9E80-46E5-9EE0-1544CE070036}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Moderation", "DorkNet.Services.Moderation\DorkNet.Services.Moderation.csproj", "{1D44C401-17B8-4314-9425-AE7A2CEDBBDE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Web", "DorkNet.Services.Web\DorkNet.Services.Web.csproj", "{F29F1B14-B61E-47CC-820A-C98BAE8F21DB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,18 +69,6 @@ Global {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x64.Build.0 = Release|Any CPU {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.ActiveCfg = Release|Any CPU {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.Build.0 = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x64.ActiveCfg = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x64.Build.0 = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x86.ActiveCfg = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x86.Build.0 = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|Any CPU.Build.0 = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x64.ActiveCfg = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x64.Build.0 = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.ActiveCfg = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.Build.0 = Release|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -155,11 +153,92 @@ Global {5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Release|x64.Build.0 = Release|Any CPU {5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Release|x86.ActiveCfg = Release|Any CPU {5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Release|x86.Build.0 = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|x64.ActiveCfg = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|x64.Build.0 = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|x86.ActiveCfg = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|x86.Build.0 = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|Any CPU.Build.0 = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|x64.ActiveCfg = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|x64.Build.0 = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|x86.ActiveCfg = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|x86.Build.0 = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|x64.ActiveCfg = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|x64.Build.0 = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|x86.ActiveCfg = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|x86.Build.0 = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|Any CPU.Build.0 = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|x64.ActiveCfg = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|x64.Build.0 = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|x86.ActiveCfg = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|x86.Build.0 = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|x64.Build.0 = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|x86.Build.0 = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|Any CPU.Build.0 = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|x64.ActiveCfg = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|x64.Build.0 = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|x86.ActiveCfg = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|x86.Build.0 = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|x64.ActiveCfg = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|x64.Build.0 = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|x86.ActiveCfg = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|x86.Build.0 = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|Any CPU.Build.0 = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|x64.ActiveCfg = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|x64.Build.0 = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|x86.ActiveCfg = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|x86.Build.0 = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|x64.ActiveCfg = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|x64.Build.0 = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|x86.ActiveCfg = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|x86.Build.0 = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|Any CPU.Build.0 = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|x64.ActiveCfg = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|x64.Build.0 = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|x86.ActiveCfg = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|x86.Build.0 = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|x64.ActiveCfg = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|x64.Build.0 = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|x86.ActiveCfg = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|x86.Build.0 = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|Any CPU.Build.0 = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|x64.ActiveCfg = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|x64.Build.0 = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|x86.ActiveCfg = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|x86.Build.0 = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|x64.ActiveCfg = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|x64.Build.0 = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|x86.ActiveCfg = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|x86.Build.0 = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|Any CPU.Build.0 = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|x64.ActiveCfg = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|x64.Build.0 = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|x86.ActiveCfg = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {70C60098-1B11-4C13-943B-A8A056429EAE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} - EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 0eb468f..3055f4b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ main also auto-detects your install. | **Version key** (sent as `X-DorkNet-Version` header) | `december_2020_12_18` | | **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` | | **Schema** | One EF `Initial` migration; SQLite + Postgres both work | -| **Runtime topology** | Full API still runs from `DorkNet.Server`; microservice gateway/service hosts are scaffolded for incremental split | +| **Runtime topology** | Standalone `DorkNet.Server`, plus gateway-fronted service slices with monolith fallback | | **Diverges from `march-2020-03-10` by** | ~170 files | Subsystems present on this branch that the March branch doesn't have: @@ -84,7 +84,7 @@ categories / playlists, and starts listening. You should see `Application started.` in the log; `curl http://localhost:8080/healthz` returns 200. -For Docker, Dokploy, and microservices-scaffold deploy notes, see +For Docker, Dokploy, and microservices deploy notes, see [docs/deploy.md](docs/deploy.md). --- @@ -201,10 +201,10 @@ Default `0` (off). |---|---| | `DorkNet.Server/` | ASP.NET Core backend (controllers, services, data, hubs, middleware) | | `DorkNet.Models/` | DTOs shared between server and client mod | -| `DorkNet.Contracts/` | Shared service names, service-map options, and health/probe contracts | -| `DorkNet.ServiceDefaults/` | Shared microservice health, JSON, HTTP client, and service identity setup | -| `DorkNet.Gateway/` | Edge/gateway host scaffold with service-map and service-health endpoints | -| `DorkNet.Services.*` | Initial identity, rooms, and notify service host scaffolds | +| `DorkNet.Contracts/` | Shared service names, route ownership, service-map options, and health/probe contracts | +| `DorkNet.ServiceDefaults/` | Shared service health, JSON, HTTP client, service identity, and route-guard setup | +| `DorkNet.Gateway/` | Edge reverse proxy with service-map, route-table, and service-health endpoints | +| `DorkNet.Services.*` | Gateway-fronted service hosts for identity, rooms, notify, content, social, commerce, platform, moderation, and web | | `DorkNet.ClientMod/` | MelonLoader IL2CPP mod — the client patcher | | `tools/` | Installers (`install-melon.ps1`, `install-legacy-client.ps1`, `remove-eac.ps1`), Cloudflare tunnel templates | | `Dockerfile`, `Dockerfile.service`, `docker-compose.microservices*.yml` | Full-server, local microservices, and Dokploy microservices deployment entrypoints | @@ -213,16 +213,19 @@ See [docs/architecture.md](docs/architecture.md) for the full mental model (project layout, request lifecycle, watch-mirror controller pattern, where to start contributing). -### Microservices scaffold +### Microservices -`docker-compose.microservices.yml` starts the new gateway, identity, -rooms, and notify service hosts plus Compose-managed Postgres and Redis +`docker-compose.microservices.yml` starts the gateway, identity, rooms, +notify, content, social, commerce, platform, moderation, web, and +monolith fallback service hosts plus Compose-managed Postgres and Redis for local testing. `docker-compose.microservices.dokploy.yml` is the Dokploy version; it adds a `cloudflared` sidecar so Cloudflare Tunnel -can route the apex and wildcard hostnames to the gateway without -Dokploy domain rows. These files do **not** replace the full -`DorkNet.Server` runtime yet; the public API is still served by the -monolith while domains are peeled into services incrementally. +can route the apex and wildcard hostnames to the gateway without Dokploy +domain rows. + +The gateway routes owned host/path slices to the dedicated service hosts. +The monolith fallback is still present for unknown route families, so +public client URLs remain stable while the split continues. The compose file intentionally does not start object storage. Point it at your separate S3-compatible instance: diff --git a/docker-compose.microservices.dokploy.yml b/docker-compose.microservices.dokploy.yml index f3e0457..d54741d 100644 --- a/docker-compose.microservices.dokploy.yml +++ b/docker-compose.microservices.dokploy.yml @@ -1,3 +1,14 @@ +x-dorknet-service-env: &dorknet-service-env + DORKNET_DOMAIN: ${DORKNET_DOMAIN:?Set DORKNET_DOMAIN in Dokploy} + DORKNET_JWT_SECRET: ${DORKNET_JWT_SECRET:?Set DORKNET_JWT_SECRET in Dokploy} + Database__Provider: postgres + ConnectionStrings__Default: Host=postgres;Port=5432;Database=dorknet;Username=dorknet;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy} + ConnectionStrings__Redis: redis:6379 + S3__Endpoint: ${DORKNET_S3_ENDPOINT:?Set DORKNET_S3_ENDPOINT in Dokploy} + S3__AccessKey: ${DORKNET_S3_ACCESS_KEY:?Set DORKNET_S3_ACCESS_KEY in Dokploy} + S3__SecretKey: ${DORKNET_S3_SECRET_KEY:?Set DORKNET_S3_SECRET_KEY in Dokploy} + S3__Region: ${DORKNET_S3_REGION:-garage} + services: gateway: build: @@ -11,13 +22,26 @@ services: DorkNet__Services__Identity: http://identity:8080 DorkNet__Services__Rooms: http://rooms:8080 DorkNet__Services__Notify: http://notify:8080 - DorkNet__Services__Monolith: "" + DorkNet__Services__Content: http://content:8080 + DorkNet__Services__Social: http://social:8080 + DorkNet__Services__Commerce: http://commerce:8080 + DorkNet__Services__Platform: http://platform:8080 + DorkNet__Services__Moderation: http://moderation:8080 + DorkNet__Services__Web: http://web:8080 + DorkNet__Services__Monolith: http://monolith:8080 expose: - "8080" depends_on: - identity - rooms - notify + - content + - social + - commerce + - platform + - moderation + - web + - monolith cloudflared: image: cloudflare/cloudflared:latest @@ -33,6 +57,8 @@ services: args: PROJECT_PATH: DorkNet.Services.Identity/DorkNet.Services.Identity.csproj APP_DLL: DorkNet.Services.Identity.dll + environment: + <<: *dorknet-service-env depends_on: - postgres - redis @@ -45,10 +71,7 @@ services: PROJECT_PATH: DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj APP_DLL: DorkNet.Services.Rooms.dll environment: - S3__Endpoint: ${DORKNET_S3_ENDPOINT:-} - S3__AccessKey: ${DORKNET_S3_ACCESS_KEY:-} - S3__SecretKey: ${DORKNET_S3_SECRET_KEY:-} - S3__Region: ${DORKNET_S3_REGION:-garage} + <<: *dorknet-service-env depends_on: - postgres - redis @@ -60,7 +83,98 @@ services: args: PROJECT_PATH: DorkNet.Services.Notify/DorkNet.Services.Notify.csproj APP_DLL: DorkNet.Services.Notify.dll + environment: + <<: *dorknet-service-env depends_on: + - postgres + - redis + + content: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Content/DorkNet.Services.Content.csproj + APP_DLL: DorkNet.Services.Content.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + social: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Social/DorkNet.Services.Social.csproj + APP_DLL: DorkNet.Services.Social.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + commerce: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj + APP_DLL: DorkNet.Services.Commerce.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + platform: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Platform/DorkNet.Services.Platform.csproj + APP_DLL: DorkNet.Services.Platform.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + moderation: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj + APP_DLL: DorkNet.Services.Moderation.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + web: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Web/DorkNet.Services.Web.csproj + APP_DLL: DorkNet.Services.Web.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + monolith: + build: + context: . + dockerfile: Dockerfile + environment: + <<: *dorknet-service-env + depends_on: + - postgres - redis postgres: @@ -68,7 +182,7 @@ services: environment: POSTGRES_DB: dorknet POSTGRES_USER: dorknet - POSTGRES_PASSWORD: dorknet_dev + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy} volumes: - microservices-postgres:/var/lib/postgresql/data diff --git a/docker-compose.microservices.yml b/docker-compose.microservices.yml index d502099..220e076 100644 --- a/docker-compose.microservices.yml +++ b/docker-compose.microservices.yml @@ -1,3 +1,14 @@ +x-dorknet-service-env: &dorknet-service-env + DORKNET_DOMAIN: ${DORKNET_DOMAIN:-localhost} + DORKNET_JWT_SECRET: ${DORKNET_JWT_SECRET:-dorknet-dev-jwt-secret-000000000000000000000000000000000000000000} + Database__Provider: postgres + ConnectionStrings__Default: Host=postgres;Port=5432;Database=dorknet;Username=dorknet;Password=${POSTGRES_PASSWORD:-dorknet_dev} + ConnectionStrings__Redis: redis:6379 + S3__Endpoint: ${DORKNET_S3_ENDPOINT:-} + S3__AccessKey: ${DORKNET_S3_ACCESS_KEY:-} + S3__SecretKey: ${DORKNET_S3_SECRET_KEY:-} + S3__Region: ${DORKNET_S3_REGION:-garage} + services: gateway: build: @@ -7,16 +18,30 @@ services: PROJECT_PATH: DorkNet.Gateway/DorkNet.Gateway.csproj APP_DLL: DorkNet.Gateway.dll environment: + DORKNET_DOMAIN: ${DORKNET_DOMAIN:-localhost} DorkNet__Services__Identity: http://identity:8080 DorkNet__Services__Rooms: http://rooms:8080 DorkNet__Services__Notify: http://notify:8080 - DorkNet__Services__Monolith: "" + DorkNet__Services__Content: http://content:8080 + DorkNet__Services__Social: http://social:8080 + DorkNet__Services__Commerce: http://commerce:8080 + DorkNet__Services__Platform: http://platform:8080 + DorkNet__Services__Moderation: http://moderation:8080 + DorkNet__Services__Web: http://web:8080 + DorkNet__Services__Monolith: http://monolith:8080 ports: - "8080:8080" depends_on: - identity - rooms - notify + - content + - social + - commerce + - platform + - moderation + - web + - monolith identity: build: @@ -25,6 +50,8 @@ services: args: PROJECT_PATH: DorkNet.Services.Identity/DorkNet.Services.Identity.csproj APP_DLL: DorkNet.Services.Identity.dll + environment: + <<: *dorknet-service-env depends_on: - postgres - redis @@ -37,10 +64,7 @@ services: PROJECT_PATH: DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj APP_DLL: DorkNet.Services.Rooms.dll environment: - S3__Endpoint: ${DORKNET_S3_ENDPOINT:-} - S3__AccessKey: ${DORKNET_S3_ACCESS_KEY:-} - S3__SecretKey: ${DORKNET_S3_SECRET_KEY:-} - S3__Region: ${DORKNET_S3_REGION:-garage} + <<: *dorknet-service-env depends_on: - postgres - redis @@ -52,7 +76,98 @@ services: args: PROJECT_PATH: DorkNet.Services.Notify/DorkNet.Services.Notify.csproj APP_DLL: DorkNet.Services.Notify.dll + environment: + <<: *dorknet-service-env depends_on: + - postgres + - redis + + content: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Content/DorkNet.Services.Content.csproj + APP_DLL: DorkNet.Services.Content.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + social: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Social/DorkNet.Services.Social.csproj + APP_DLL: DorkNet.Services.Social.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + commerce: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj + APP_DLL: DorkNet.Services.Commerce.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + platform: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Platform/DorkNet.Services.Platform.csproj + APP_DLL: DorkNet.Services.Platform.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + moderation: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj + APP_DLL: DorkNet.Services.Moderation.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + web: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Web/DorkNet.Services.Web.csproj + APP_DLL: DorkNet.Services.Web.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + monolith: + build: + context: . + dockerfile: Dockerfile + environment: + <<: *dorknet-service-env + depends_on: + - postgres - redis postgres: @@ -60,7 +175,7 @@ services: environment: POSTGRES_DB: dorknet POSTGRES_USER: dorknet - POSTGRES_PASSWORD: dorknet_dev + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dorknet_dev} volumes: - microservices-postgres:/var/lib/postgresql/data diff --git a/docs/architecture.md b/docs/architecture.md index b8b2fe8..c9c1b0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,10 +18,16 @@ DorkNet.sln ├── DorkNet.Models Shared DTO + serialization types ├── DorkNet.Contracts Shared service contracts for the service split ├── DorkNet.ServiceDefaults Shared service-host setup -├── DorkNet.Gateway Gateway/edge host scaffold -├── DorkNet.Services.Identity Identity service scaffold -├── DorkNet.Services.Rooms Rooms service scaffold -├── DorkNet.Services.Notify Notify service scaffold +├── DorkNet.Gateway Gateway/edge reverse proxy +├── DorkNet.Services.Identity Auth/accounts service host +├── DorkNet.Services.Rooms Rooms/matchmaking service host +├── DorkNet.Services.Notify Notify/chat service host +├── DorkNet.Services.Content CDN/images/photos/storage service host +├── DorkNet.Services.Social Clubs/groups/events/social service host +├── DorkNet.Services.Commerce Store/econ/inventory service host +├── DorkNet.Services.Platform Service directory/config/platform service host +├── DorkNet.Services.Moderation Bug reporting/moderation service host +├── DorkNet.Services.Web Public/admin/feed web host └── DorkNet.ClientMod MelonLoader 6 IL2CPP mod — the client patcher ``` @@ -29,12 +35,18 @@ DorkNet.sln |---|---|---| | `DorkNet.Server` | REST API, SignalR hub, admin SPA host, static-site host, image CDN, matchmaking, persistence | .NET 9 | | `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 9 | -| `DorkNet.Contracts` | Service names, service-map options, health responses, probe responses | .NET 9 | -| `DorkNet.ServiceDefaults` | Common microservice health checks, JSON defaults, HTTP clients, service identity | .NET 9 | -| `DorkNet.Gateway` | Edge/gateway scaffold; exposes internal service map and service health probes | .NET 9 | -| `DorkNet.Services.Identity` | Future home for accounts, auth, platform login, JWT issuance | .NET 9 | -| `DorkNet.Services.Rooms` | Future home for rooms, room keys, matchmaking, discovery | .NET 9 | -| `DorkNet.Services.Notify` | Future home for SignalR edge, notification fan-out, presence, request logs | .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.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.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.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 | +| `DorkNet.Services.Social` | Clubs, groups, announcements, player events, subscriptions | .NET 9 | +| `DorkNet.Services.Commerce` | Catalog, storefronts, econ, inventory, inventions | .NET 9 | +| `DorkNet.Services.Platform` | Service URLs, config, version checks, geo, strings, telemetry-style surfaces | .NET 9 | +| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, admin API, testcase routes | .NET 9 | +| `DorkNet.Services.Web` | Public site, admin static host, feed static host, site API | .NET 9 | | `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 | `ClientMod` applies the client-side patches needed to point the 2020 @@ -66,31 +78,42 @@ DorkNet.Server/ ## Runtime topology -`DorkNet.Server` is still the only production-complete runtime. It owns -the full public Rec Room-compatible HTTP surface, SignalR hub, static -hosts, database bootstrap, Redis-backed ephemeral state, and S3-backed -blob storage. +`DorkNet.Server` remains the compatibility fallback and full standalone +runtime. It can still run by itself, serving the full public +Rec Room-compatible HTTP surface, SignalR hub, static hosts, database +bootstrap, Redis-backed ephemeral state, and S3-backed blob storage. -The microservice projects are the migration scaffold. They are wired into -the solution and Docker Compose, but they only expose internal health and -capability endpoints today: +The microservice shape is a gateway-fronted split: + +- `DorkNet.Gateway` reverse-proxies public requests by host/path. +- `DorkNet.Services.Identity`, `DorkNet.Services.Rooms`, + `DorkNet.Services.Notify`, `DorkNet.Services.Content`, + `DorkNet.Services.Social`, `DorkNet.Services.Commerce`, + `DorkNet.Services.Platform`, `DorkNet.Services.Moderation`, and + `DorkNet.Services.Web` run the shared server stack behind a route + ownership guard. That keeps response shapes identical while moving + traffic by domain/path slice. +- `DorkNet.Server` runs as `monolith` behind the gateway for unknown + route families that are not assigned to a dedicated service yet. - `DorkNet.Gateway` exposes `/internal/services` and - `/internal/services/health`. + `/internal/services/health`, plus `/internal/routes` for the active + proxy table. - `DorkNet.Services.Identity` exposes `/internal/identity/capabilities`. - `DorkNet.Services.Rooms` exposes `/internal/rooms/capabilities`. - `DorkNet.Services.Notify` exposes `/internal/notify/capabilities`. +- The other slices expose matching `/internal/{service}/capabilities` + endpoints. -`docker-compose.microservices.yml` starts gateway, identity, rooms, and -notify alongside Compose-managed Postgres and Redis for local testing. +`docker-compose.microservices.yml` starts gateway, all dedicated service +slices, the monolith fallback, and Compose-managed Postgres and Redis for +local testing. `docker-compose.microservices.dokploy.yml` is the Dokploy shape: it adds a `cloudflared` sidecar that joins the same Compose network and forwards Cloudflare Tunnel traffic to `http://gateway:8080`. Object storage is external S3-compatible storage configured with `DORKNET_S3_*` -environment variables; the compose files do not run MinIO or Garage. The -next migration step is to move one public route group at a time behind -the gateway while preserving existing URLs and response shapes. +environment variables; the compose files do not run MinIO or Garage. ### Controllers/ groupings @@ -277,10 +300,11 @@ worse. intentional — the decompiled source is provenance only, not shippable. Comments give a future contributor a breadcrumb to re-derive the wire shape if the decompile changes. -- **Microservices are scaffolded, not fully cut over.** The gateway and - service hosts compile and run, but the public API still lives in - `DorkNet.Server`. Move endpoints by domain slice, not by copying random - controllers. +- **Microservices use shared server code during extraction.** The + dedicated service hosts run the proven controller/service stack behind + route guards instead of copying controllers into new projects. This + keeps the client contract stable while route families are peeled away + from the monolith fallback. - **Two-provider EF.** SQLite and Postgres each have their own EnsureCreated/Migrate dance because some Postgres-only column types drift the model snapshot. The split is annoying but lets one diff --git a/docs/deploy.md b/docs/deploy.md index 4d3aecb..f007725 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -5,13 +5,12 @@ This branch has two deployable shapes: - **Production/full API:** build the root `Dockerfile`, which runs `DorkNet.Server`. This is still the runtime that serves every public Rec Room-compatible endpoint. -- **Microservices scaffold:** deploy `docker-compose.microservices.yml` +- **Microservices:** deploy `docker-compose.microservices.yml` for local testing or `docker-compose.microservices.dokploy.yml` on - Dokploy. Both start `DorkNet.Gateway`, - `DorkNet.Services.Identity`, `DorkNet.Services.Rooms`, - `DorkNet.Services.Notify`, Postgres, and Redis. These hosts currently - expose internal health/capability endpoints only; public routes move - behind the gateway incrementally. + Dokploy. Both start `DorkNet.Gateway`, the dedicated service slices, + the `DorkNet.Server` monolith fallback, Postgres, and Redis. The + gateway routes public traffic to owned service slices and sends + unknown route families to the fallback. Object storage is always a **separate S3-compatible instance**. The microservices compose files do not start MinIO/Garage. @@ -120,10 +119,9 @@ service URLs returned to the client by `ConfigService`. --- -## Dokploy: microservices scaffold +## Dokploy: microservices -Use this to run the new gateway/service-host layout. It is not yet a -replacement for the full public API. +Use this to run the gateway/service-host layout. Create a Dokploy **Compose** service from one of these files: @@ -136,10 +134,17 @@ Both compose files provide: | Service | Purpose | |---|---| -| `gateway` | Edge host scaffold; service map and service health probes | -| `identity` | Future identity/auth/accounts service | -| `rooms` | Future rooms/matchmaking/discovery service | -| `notify` | Future notification/presence service | +| `gateway` | Edge reverse proxy; service map, route table, and service health probes | +| `identity` | Auth, accounts, and platform-login route slice | +| `rooms` | Rooms, room keys, playlists, matchmaking, and discovery route slice | +| `notify` | Notify, messages/chat, SignalR, and notification route slice | +| `content` | CDN paths, uploads, images, photos, room blobs, and storage route slice | +| `social` | Clubs, groups, announcements, player events, subscriptions route slice | +| `commerce` | Catalog, storefronts, econ, inventory, inventions route slice | +| `platform` | Service directory, config, version checks, geo, strings, and platform route slice | +| `moderation` | Bug reporting, player reporting, sanitize, admin API, and testcase route slice | +| `web` | Apex/www/admin/feed static hosts and site API route slice | +| `monolith` | Fallback full server for unknown route families not assigned to a service yet | | `postgres` | Internal Postgres for the service network | | `redis` | Internal Redis for ephemeral state and fan-out | | `cloudflared` | Cloudflare Tunnel sidecar; Dokploy file only | @@ -155,6 +160,8 @@ Set these Dokploy Compose environment variables: ```env DORKNET_DOMAIN=yourdomain.com +DORKNET_JWT_SECRET=replace-with-at-least-64-random-characters +POSTGRES_PASSWORD=replace-with-a-random-db-password CLOUDFLARE_TUNNEL_TOKEN= ``` @@ -165,15 +172,16 @@ yourdomain.com -> http://gateway:8080 *.yourdomain.com -> http://gateway:8080 ``` -The wildcard covers `api`, `auth`, `rooms`, `notify`, and the other -client-facing subdomains. The apex rule is separate because wildcard DNS -does not cover the root domain. Let Cloudflare create the tunnel DNS -records, or create CNAME records to the tunnel target Cloudflare gives -you. +The wildcard covers `api`, `auth`, `rooms`, `notify`, `cdn`, `clubs`, +`commerce`, and the other client-facing subdomains. The apex rule is +separate because wildcard DNS does not cover the root domain. Let +Cloudflare create the tunnel DNS records, or create CNAME records to the +tunnel target Cloudflare gives you. -When the gateway starts carrying the public API, every public DorkNet -domain listed above should point to `gateway`. Until then, keep the real -player-facing domains on the full `DorkNet.Server` application. +Every public DorkNet domain listed above should point to the gateway. +The gateway preserves the original Host header so service code still sees +`auth.yourdomain.com`, `rooms.yourdomain.com`, and the other client +hosts. Set these Compose environment variables in Dokploy: @@ -194,11 +202,12 @@ Useful smoke checks after deploy: curl https://api.yourdomain.com/healthz curl https://api.yourdomain.com/internal/services curl https://api.yourdomain.com/internal/services/health +curl https://api.yourdomain.com/internal/routes ``` -Expected during scaffold-only deploys: `/internal/services/health` -reports gateway-visible status for identity, rooms, and notify. It does -not prove the full game API has moved yet. +`/internal/routes` shows which host/path patterns route to each service +slice or the monolith fallback. `/internal/services/health` reports +gateway-visible health for every backend service. --- @@ -235,7 +244,7 @@ S3__TimeoutSeconds=300 ``` For the microservices compose file, use the `DORKNET_S3_*` equivalents; -Compose maps them into `S3__*` for the rooms service. +Compose maps them into `S3__*` for the service containers. --- diff --git a/tools/cloudflared.README.md b/tools/cloudflared.README.md index 8f219c5..904acbb 100644 --- a/tools/cloudflared.README.md +++ b/tools/cloudflared.README.md @@ -87,8 +87,9 @@ visitor → https://api.localhost/api/versioncheck/v4 → Cloudflare edge (real LE cert, validated) → cloudflared tunnel → cloudflared (running on your machine) - → https://localhost:443 (Host: api.localhost, mkcert cert) - → DorkNet.Server HostFiltering + subdomain-aware handlers + → http://gateway:8080 (Host preserved) + → DorkNet.Gateway reverse proxy + → dedicated service slice / monolith fallback ``` The leg from cloudflared → localhost has `noTLSVerify: true` because From 94bad2912b2f9b3c38188d789cd54310502fe552 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:31:41 +0300 Subject: [PATCH 04/15] Add endpoint contract tool and GitHub workflow --- .../workflows.disabled/endpoint-contract.yml | 78 +++ .../DorkNet.EndpointContract.csproj | 14 + tools/DorkNet.EndpointContract/Program.cs | 509 ++++++++++++++++++ 3 files changed, 601 insertions(+) create mode 100644 .github/workflows.disabled/endpoint-contract.yml create mode 100644 tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj create mode 100644 tools/DorkNet.EndpointContract/Program.cs 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/tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj b/tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj new file mode 100644 index 0000000..10a76b3 --- /dev/null +++ b/tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/tools/DorkNet.EndpointContract/Program.cs b/tools/DorkNet.EndpointContract/Program.cs new file mode 100644 index 0000000..9c8bed1 --- /dev/null +++ b/tools/DorkNet.EndpointContract/Program.cs @@ -0,0 +1,509 @@ +using System.Net; +using System.Reflection; +using System.Runtime.Loader; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; + +var options = EndpointContractOptions.Parse(args); +var serverAssembly = LoadServerAssembly(options.ServerAssemblyPath); +var routes = DiscoverRoutes(serverAssembly).OrderBy(r => r.Method).ThenBy(r => r.SamplePath).ToArray(); +var results = new List(); +var failures = new List(); + +ValidateRouteShapes(routes, failures); + +Console.WriteLine($"Using server assembly: {serverAssembly.Location}"); +Console.WriteLine($"Server assembly timestamp (UTC): {File.GetLastWriteTimeUtc(serverAssembly.Location):O}"); +Console.WriteLine($"Discovered {routes.Length} HTTP endpoint contracts."); + +if (options.BaseUrl is { } baseUrl) +{ + using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") }; + http.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + + foreach (var route in routes) + { + var result = await ProbeAsync(http, route, options); + results.Add(result); + if (!result.Passed) + failures.Add($"{route.Source}: {route.Method} {route.SamplePath} {result.Expected} but got {result.Observed}"); + } +} +else +{ + foreach (var route in routes) + { + results.Add(new EndpointProbeResult( + route.Method, + route.SamplePath, + route.Source, + route.RequiresAuthorization, + "route-shape", + null, + null, + true)); + } +} + +if (options.ReportPath is { } reportPath) + WriteReport(reportPath, serverAssembly, routes.Length, results, failures); + +foreach (var result in results) +{ + var status = result.StatusCode is int code ? code.ToString() : "-"; + Console.WriteLine($"{(result.Passed ? "PASS" : "FAIL"),-4} {result.Method,-6} {status,-3} {result.Path,-72} {result.Source}"); +} + +if (failures.Count == 0) + return 0; + +Console.Error.WriteLine(); +Console.Error.WriteLine("Endpoint contract failed:"); +foreach (var failure in failures) + Console.Error.WriteLine($"- {failure}"); +return 1; + +static void ValidateRouteShapes(IEnumerable routes, List failures) +{ + foreach (var route in routes) + { + if (string.IsNullOrWhiteSpace(route.Template)) + failures.Add($"{route.Source}: empty route template"); + + if (route.SamplePath.Contains('{') || route.SamplePath.Contains('}')) + failures.Add($"{route.Source}: unresolved route parameter in {route.SamplePath}"); + + if (route.SamplePath.Contains('[') || route.SamplePath.Contains(']')) + failures.Add($"{route.Source}: unresolved route token in {route.SamplePath}"); + + if (!route.SamplePath.StartsWith('/')) + failures.Add($"{route.Source}: sample path must be absolute: {route.SamplePath}"); + } +} + +static async Task ProbeAsync( + HttpClient http, + EndpointContract route, + EndpointContractOptions options) +{ + using var request = new HttpRequestMessage(ToHttpMethod(route.Method), route.SamplePath.TrimStart('/')); + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.TryAddWithoutValidation("X-DorkNet-Version", options.VersionHeader); + request.Headers.UserAgent.ParseAdd("DorkNetEndpointContract/1.0"); + + if (NeedsRequestBody(request.Method)) + request.Content = CreateContent(route); + + try + { + using var response = await http.SendAsync(request); + var statusCode = (int)response.StatusCode; + var passed = ResponseMatchesContract(route, response.StatusCode); + return new EndpointProbeResult( + route.Method, + route.SamplePath, + route.Source, + route.RequiresAuthorization, + ExpectedContract(route), + statusCode, + null, + passed); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return new EndpointProbeResult( + route.Method, + route.SamplePath, + route.Source, + route.RequiresAuthorization, + ExpectedContract(route), + null, + ex.Message, + false); + } +} + +static bool ResponseMatchesContract(EndpointContract route, HttpStatusCode statusCode) +{ + if (route.RequiresAuthorization) + return statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden; + + return (int)statusCode < 500 + && statusCode != HttpStatusCode.MethodNotAllowed; +} + +static string ExpectedContract(EndpointContract route) +{ + return route.RequiresAuthorization + ? "unauthenticated protected endpoint should return 401 or 403" + : "public endpoint should return a non-5xx, non-405 response"; +} + +static HttpMethod ToHttpMethod(string method) +{ + return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase) + ? HttpMethod.Get + : new HttpMethod(method); +} + +static bool NeedsRequestBody(HttpMethod method) +{ + return method == HttpMethod.Post + || method == HttpMethod.Put + || method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase); +} + +static HttpContent CreateContent(EndpointContract route) +{ + 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 + { + ["grant_type"] = "password", + ["username"] = "endpoint-contract", + ["password"] = "endpoint-contract", + ["device_id"] = "endpoint-contract-device", + ["platform"] = "0", + ["platform_id"] = "endpoint-contract-platform", + }); + } + + if (consumes.Any(c => c.Contains("multipart/form-data"))) + { + var content = new MultipartFormDataContent(); + content.Add(new StringContent("endpoint-contract"), "name"); + 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"); + + return new StringContent("{}", Encoding.UTF8, "application/json"); +} + +static IEnumerable DiscoverRoutes(Assembly serverAssembly) +{ + var controllerBaseType = typeof(ControllerBase); + + foreach (var controller in serverAssembly.GetTypes() + .Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t))) + { + 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), + ConsumesContentTypes(controller, method)); + } + } + } + } + } +} + +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(); +} + +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; +} + +static string ControllerName(Type controller) +{ + const string suffix = "Controller"; + return controller.Name.EndsWith(suffix, StringComparison.Ordinal) + ? controller.Name[..^suffix.Length] + : controller.Name; +} + +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; +} + +static string ReplaceRouteTokens(string template, string controllerName, string actionName) +{ + return template + .Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase) + .Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase); +} + +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 (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; +} + +static Assembly LoadServerAssembly(string? configuredPath) +{ + if (!string.IsNullOrWhiteSpace(configuredPath)) + return LoadAssemblyFromPath(Path.GetFullPath(configuredPath)); + + var repositoryRoot = FindRepositoryRoot(); + var candidates = new[] + { + Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"), + Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll") + }; + var existingCandidates = candidates.Where(File.Exists).ToArray(); + if (existingCandidates.Length == 0) + { + throw new FileNotFoundException( + "Could not find a built DorkNet.Server assembly. Build DorkNet.Server first or pass --server-assembly.", + string.Join(Environment.NewLine, candidates)); + } + + if (existingCandidates.Length > 1) + { + throw new InvalidOperationException( + "Multiple DorkNet.Server assemblies were found. Pass --server-assembly so endpoint checks validate the intended build." + + Environment.NewLine + + string.Join(Environment.NewLine, existingCandidates)); + } + + return LoadAssemblyFromPath(existingCandidates[0]); +} + +static Assembly LoadAssemblyFromPath(string assemblyPath) +{ + var assemblyDirectory = Path.GetDirectoryName(assemblyPath) + ?? throw new DirectoryNotFoundException($"Could not resolve assembly directory for {assemblyPath}."); + + AssemblyLoadContext.Default.Resolving += (_, assemblyName) => + { + var dependencyPath = Path.Combine(assemblyDirectory, assemblyName.Name + ".dll"); + return File.Exists(dependencyPath) + ? AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyPath) + : null; + }; + + return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); +} + +static string FindRepositoryRoot() +{ + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "DorkNet.Server", "DorkNet.Server.csproj"))) + return current.FullName; + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root from the endpoint contract tool output path."); +} + +static void WriteReport( + string reportPath, + Assembly serverAssembly, + int routeCount, + IReadOnlyCollection results, + IReadOnlyCollection failures) +{ + var report = new EndpointContractReport( + serverAssembly.Location, + File.GetLastWriteTimeUtc(serverAssembly.Location), + routeCount, + results.Count(r => r.Passed), + results.Count(r => !r.Passed), + failures, + results); + + var fullPath = Path.GetFullPath(reportPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); +} + +internal sealed record EndpointContract( + string Method, + string Template, + string SamplePath, + string Source, + bool RequiresAuthorization, + string[] Consumes); + +internal sealed record EndpointProbeResult( + string Method, + string Path, + string Source, + bool RequiresAuthorization, + string Expected, + int? StatusCode, + string? Error, + bool Passed) +{ + public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response"; +} + +internal sealed record EndpointContractReport( + string ServerAssembly, + DateTime ServerAssemblyTimestampUtc, + int RouteCount, + int Passed, + int Failed, + IReadOnlyCollection Failures, + IReadOnlyCollection Results); + +internal sealed class EndpointContractOptions +{ + public string? ServerAssemblyPath { get; private init; } + public string? BaseUrl { get; private init; } + public string? ReportPath { get; private init; } + public string VersionHeader { get; private init; } = "december_2020_12_18"; + public int TimeoutSeconds { get; private init; } = 10; + + public static EndpointContractOptions Parse(string[] args) + { + string? serverAssemblyPath = null; + string? baseUrl = null; + string? reportPath = null; + var versionHeader = "december_2020_12_18"; + var timeoutSeconds = 10; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + switch (arg) + { + case "--server-assembly": + serverAssemblyPath = NextValue(args, ref i, arg); + break; + case "--base-url": + baseUrl = NextValue(args, ref i, arg); + break; + case "--report": + reportPath = NextValue(args, ref i, arg); + break; + case "--version": + versionHeader = NextValue(args, ref i, arg); + break; + case "--timeout-seconds": + timeoutSeconds = int.Parse(NextValue(args, ref i, arg)); + break; + case "--help": + case "-h": + Console.WriteLine("Usage: DorkNet.EndpointContract [--server-assembly PATH] [--base-url URL] [--report PATH]"); + Environment.Exit(0); + break; + default: + throw new ArgumentException($"Unknown argument: {arg}"); + } + } + + return new EndpointContractOptions + { + ServerAssemblyPath = serverAssemblyPath, + BaseUrl = baseUrl, + ReportPath = reportPath, + VersionHeader = versionHeader, + TimeoutSeconds = timeoutSeconds, + }; + } + + private static string NextValue(string[] args, ref int index, string option) + { + if (index + 1 >= args.Length) + throw new ArgumentException($"{option} requires a value."); + + index++; + return args[index]; + } +} + +partial class Program +{ + [GeneratedRegex(@"\{[*]?(?[^}:=\?]+)(?:[^}=\?]+)?(?\?)?(?=[^}]+)?\}")] + private static partial Regex RouteParameterRegex(); +} From 46e2d879cd9f1b19c8f4e0c3949d0e390f25b6c4 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:49:35 +0300 Subject: [PATCH 05/15] Add new SVG logo for DorkNet branding --- Dockerfile.service | 5 +- DorkNet.Server/DorkNet.Server.csproj | 9 +++ .../wwwroot/admin/brand/dorknet.svg | 71 +++++++++++++++++++ DorkNet.Server/wwwroot/site/brand/dorknet.svg | 71 +++++++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 DorkNet.Server/wwwroot/admin/brand/dorknet.svg create mode 100644 DorkNet.Server/wwwroot/site/brand/dorknet.svg diff --git a/Dockerfile.service b/Dockerfile.service index cd9a6ee..a6b8d3b 100644 --- a/Dockerfile.service +++ b/Dockerfile.service @@ -5,7 +5,10 @@ ARG PROJECT_PATH WORKDIR /src COPY . . -RUN dotnet publish "${PROJECT_PATH}" -c Release -p:SkipAdminUIBuild=true -o /app/publish +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 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 @@ + + + + + +