diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 0808c4a..b9ed086 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # dotenv files .env +deploy/aspire-output/ # User-specific files *.rsuser diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b14d8a --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# TenWholeYears.Server + +Server for the TenWholeYears Game Client. A decompilation of the first public Rec Room build. + +## Basic Setup + +Prerequisites: + +- .NET 10 SDK +- Docker Desktop or another container runtime, when using `AppHost` +- A Steam Web API key, if using platform validation. + +Restore and build: + +```powershell +dotnet restore RecNet.sln +dotnet build RecNet.sln +``` + +Run the full local stack with Aspire: + +```powershell +dotnet run --project src\AppHost\AppHost.csproj +``` + + + +## Configuration + +Use user secrets for local development. + +For the API: + +```powershell +dotnet user-secrets set "Jwt:Secret" "replace-with-a-long-random-secret" --project src\API\API.csproj +``` + +Optional API secrets: + +```powershell +dotnet user-secrets set "Jwt:Issuer" "http://localhost:5155" --project src\API\API.csproj +dotnet user-secrets set "Jwt:Audience" "TenWholeYears" --project src\API\API.csproj +dotnet user-secrets set "Jwt:ExpiryMinutes" "60" --project src\API\API.csproj +dotnet user-secrets set "Steam:ApiKey" "your-steam-web-api-key" --project src\API\API.csproj +dotnet user-secrets set "Steam:AppId" "480" --project src\API\API.csproj +dotnet user-secrets set "Neutrino:ValidateSecret" "true" --project src\API\API.csproj +dotnet user-secrets set "Neutrino:Secret" "replace-with-neutrino-secret" --project src\API\API.csproj +dotnet user-secrets set "RecNet:UseForwardedHeaders" "false" --project src\API\API.csproj +``` + +## Production Config With Environment Variables + +.env example: + +```powershell +Jwt__Secret = "replace-with-a-long-random-secret" +Jwt__Issuer = "https://your-api.example.com" +Jwt__Audience = "TenWholeYears" +Jwt__ExpiryMinutes = "60" +Steam__ApiKey = "your-steam-web-api-key" +Steam__AppId = "480" +Neutrino__ValidateSecret = "true" +Neutrino__Secret = "replace-with-neutrino-secret" +RecNet__UseForwardedHeaders = "true" +``` + +## Runtime Server Configs + +The server has configs in the `ServerConfigs` database table that are able to be adjusted while the server is running. Keys are strings and values are stored as JSON. + +| Key | Type | Default | +| --- | --- | --- | +| `Config:MOTD` | `string` | `"Ten Whole Years!"` | +| `Profiles:IgnoreAuthValidation` | `boolean` | `true` | +| `Profiles:NameGen` | object | `DefaultNameGenConfig.Value` | + +`Profiles:NameGen` expects this shape: + +```json +{ + "Adjectives": [ + "Adorable", + "Brave" + ], + "Nouns": [ + "Pony", + "Tiger" + ] +} +``` + +## Notes + + +- Set `RecNet:UseForwardedHeaders` to `true` only when the API is behind a trusted reverse proxy. diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..76980e6 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,22 @@ +# Copy this file to .env and fill in production values before deploying. + +# Registry login used by deploy/push-images.ps1. +REGISTRY_ENDPOINT=git.recroomarchive.org +REGISTRY_REPOSITORY=recroomarchive-deploy/tenwholeyears-server +REGISTRY_USERNAME=replace-with-registry-username +REGISTRY_TOKEN=replace-with-registry-token +IMAGE_TAG=latest + +# Database. +POSTGRES_PASSWORD=replace-with-long-random-postgres-password + +# API configuration. +Jwt__Secret=replace-with-at-least-32-random-characters +Jwt__Issuer=https://your-api.example.com +Jwt__Audience=TenWholeYears +Jwt__ExpiryMinutes=60 +Steam__ApiKey=replace-with-steam-web-api-key +Steam__AppId=480 +Neutrino__ValidateSecret=true +Neutrino__Secret=replace-with-neutrino-secret +RecNet__UseForwardedHeaders=true diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..c0029e9 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,34 @@ +## Build and Push Images + +1. Copy `.env.example` to `.env` and fill in production values. +2. Run the Aspire-native push pipeline: + +```powershell +.\deploy\push-images.ps1 +``` + +Or from a POSIX shell: + +```sh +sh ./deploy/push-images.sh +``` + +The shell script expects the Aspire CLI to be installed in that shell. In WSL, install it with the official Aspire installer: + +```sh +curl -sSL https://aspire.dev/install.sh | bash +``` + +Or as a .NET tool: + +```sh +dotnet tool install -g Aspire.Cli +``` + +The script loads `deploy/.env` when it exists, authenticates to `git.recroomarchive.org` using `REGISTRY_USERNAME` and `REGISTRY_TOKEN`, then runs `aspire do push`. + +The included `docker-compose.yml` consumes the pushed images: + +```powershell +docker compose --env-file .env up -d +``` diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..3441102 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,52 @@ +name: tenwholeyears-server + +services: + postgres: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_DB: recnet + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - recnet-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d recnet"] + interval: 10s + timeout: 5s + retries: 10 + + migrationservice: + image: ${REGISTRY_ENDPOINT:-git.recroomarchive.org}/${REGISTRY_REPOSITORY}/migrationservice:${IMAGE_TAG:-latest} + restart: "no" + environment: + ConnectionStrings__recnet: Host=postgres;Port=5432;Database=recnet;Username=postgres;Password=${POSTGRES_PASSWORD} + depends_on: + postgres: + condition: service_healthy + + api: + image: ${REGISTRY_ENDPOINT:-git.recroomarchive.org}/${REGISTRY_REPOSITORY}/api:${IMAGE_TAG:-latest} + restart: unless-stopped + ports: + - "${API_HTTP_PORT:-8080}:8080" + environment: + ASPNETCORE_HTTP_PORTS: 8080 + ConnectionStrings__recnet: Host=postgres;Port=5432;Database=recnet;Username=postgres;Password=${POSTGRES_PASSWORD} + Jwt__Secret: ${Jwt__Secret} + Jwt__Issuer: ${Jwt__Issuer} + Jwt__Audience: ${Jwt__Audience} + Jwt__ExpiryMinutes: ${Jwt__ExpiryMinutes} + Steam__ApiKey: ${Steam__ApiKey} + Steam__AppId: ${Steam__AppId} + Neutrino__ValidateSecret: ${Neutrino__ValidateSecret} + Neutrino__Secret: ${Neutrino__Secret} + RecNet__UseForwardedHeaders: ${RecNet__UseForwardedHeaders} + depends_on: + postgres: + condition: service_healthy + migrationservice: + condition: service_completed_successfully + +volumes: + recnet-postgres-data: diff --git a/deploy/push-images.ps1 b/deploy/push-images.ps1 new file mode 100644 index 0000000..e66126f --- /dev/null +++ b/deploy/push-images.ps1 @@ -0,0 +1,49 @@ +param( + [string] $Environment = "Production", + [string] $AppHostPath = "", + [string] $OutputPath = "" +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($AppHostPath)) { + $AppHostPath = Join-Path $PSScriptRoot "../src/AppHost/AppHost.csproj" +} + +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $PSScriptRoot "aspire-output" +} + +$envPath = Join-Path $PSScriptRoot ".env" +if (Test-Path $envPath) { + Get-Content $envPath | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object { + $name, $value = $_ -split '=', 2 + if (-not [string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + } +} + +$requiredEnvironmentVariables = @( + "REGISTRY_USERNAME", + "REGISTRY_TOKEN", + "REGISTRY_REPOSITORY" +) + +$missing = $requiredEnvironmentVariables | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) } +if ($missing.Count -gt 0) { + throw "Missing required environment variables: $($missing -join ', '). Load deploy/.env values before running this script." +} + +if ([string]::IsNullOrWhiteSpace($env:IMAGE_TAG)) { + $env:IMAGE_TAG = "latest" +} + +$env:REGISTRY_ENDPOINT = "git.recroomarchive.org" + +$env:REGISTRY_TOKEN | docker login $env:REGISTRY_ENDPOINT -u $env:REGISTRY_USERNAME --password-stdin + +aspire do push ` + --apphost $AppHostPath ` + --environment $Environment ` + --output-path $OutputPath diff --git a/deploy/push-images.sh b/deploy/push-images.sh new file mode 100644 index 0000000..a7061dd --- /dev/null +++ b/deploy/push-images.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env sh +set -eu + +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) + +ENVIRONMENT=${ENVIRONMENT:-Production} +APPHOST_PATH=${APPHOST_PATH:-"$script_dir/../src/AppHost/AppHost.csproj"} +OUTPUT_PATH=${OUTPUT_PATH:-"$script_dir/aspire-output"} + +env_file="$script_dir/.env" +if [ -f "$env_file" ]; then + while IFS= read -r line || [ -n "$line" ]; do + line=$(printf '%s' "$line" | tr -d '\r') + case "$line" in + ""|\#*) continue ;; + esac + + key=${line%%=*} + value=${line#*=} + + case "$key" in + [A-Za-z_][A-Za-z0-9_]*) ;; + *) continue ;; + esac + + eval "existing=\${$key-}" + if [ -n "$key" ] && [ -z "$existing" ]; then + export "$key=$value" + fi + done < "$env_file" +fi + +missing="" +for name in REGISTRY_USERNAME REGISTRY_TOKEN REGISTRY_REPOSITORY; do + eval "value=\${$name-}" + if [ -z "$value" ]; then + if [ -z "$missing" ]; then + missing=$name + else + missing="$missing, $name" + fi + fi +done + +if [ -n "$missing" ]; then + echo "Missing required environment variables: $missing. Load deploy/.env values before running this script." >&2 + exit 1 +fi + +IMAGE_TAG=${IMAGE_TAG:-latest} +REGISTRY_ENDPOINT=git.recroomarchive.org +export IMAGE_TAG REGISTRY_ENDPOINT + +if [ -n "${ASPIRE_CLI-}" ]; then + aspire_cli=$ASPIRE_CLI +elif command -v aspire >/dev/null 2>&1; then + aspire_cli=aspire +elif [ -x "$HOME/.aspire/bin/aspire" ]; then + aspire_cli="$HOME/.aspire/bin/aspire" +elif [ -x "$HOME/.dotnet/tools/aspire" ]; then + aspire_cli="$HOME/.dotnet/tools/aspire" +else + echo "Aspire CLI was not found in this shell." >&2 + echo "Install it in WSL with: curl -sSL https://aspire.dev/install.sh | bash" >&2 + echo "Or install it as a .NET tool with: dotnet tool install -g Aspire.Cli" >&2 + echo "If it is installed somewhere else, rerun with: ASPIRE_CLI=/path/to/aspire sh ./deploy/push-images.sh" >&2 + exit 1 +fi + +printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY_ENDPOINT" -u "$REGISTRY_USERNAME" --password-stdin + +"$aspire_cli" do push \ + --apphost "$APPHOST_PATH" \ + --environment "$ENVIRONMENT" \ + --output-path "$OUTPUT_PATH" diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs index 71f2c6b..c8b7aba 100644 --- a/src/AppHost/AppHost.cs +++ b/src/AppHost/AppHost.cs @@ -2,6 +2,16 @@ var builder = DistributedApplication.CreateBuilder(args); var postgresPassword = builder.AddParameter("postgres-password", secret: true); +var imageTag = builder.Configuration["IMAGE_TAG"] ?? "latest"; +var registryRepository = builder.Configuration["REGISTRY_REPOSITORY"] ?? "recroomarchive/tenwholeyears-server"; + +#pragma warning disable ASPIRECOMPUTE003 +var registry = builder.AddContainerRegistry( + "recroomarchive-registry", + "git.recroomarchive.org", + registryRepository); +#pragma warning restore ASPIRECOMPUTE003 + // Infra var postgres = builder.AddPostgres("postgres") .WithDataVolume("recnet-postgres-data") @@ -12,14 +22,22 @@ var postgres = builder.AddPostgres("postgres") var db = postgres.AddDatabase("recnet"); +#pragma warning disable ASPIRECOMPUTE003, ASPIREPIPELINES003 + // Services var migrationService = builder.AddProject("migrationservice") .WithReference(db) - .WaitFor(db); + .WaitFor(db) + .WithContainerRegistry(registry) + .WithRemoteImageTag(imageTag); builder.AddProject("api") .WithReference(db) .WaitFor(db) - .WaitForCompletion(migrationService); + .WaitForCompletion(migrationService) + .WithContainerRegistry(registry) + .WithRemoteImageTag(imageTag); builder.Build().Run(); + +#pragma warning restore ASPIRECOMPUTE003, ASPIREPIPELINES003 diff --git a/src/RecNet.Infrastructure/Services/Steam/SteamOptions.cs b/src/RecNet.Infrastructure/Services/Steam/SteamOptions.cs index 7b69553..afd46f6 100644 --- a/src/RecNet.Infrastructure/Services/Steam/SteamOptions.cs +++ b/src/RecNet.Infrastructure/Services/Steam/SteamOptions.cs @@ -4,5 +4,4 @@ public class SteamOptions { public string ApiKey { get; set; } = string.Empty; public uint AppId { get; set; } = 480; // Spacewar - public string Identity { get; set; } = "recnet"; }