From 060d838685efc103aea773ea8d57061c6203a3cf 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] 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