diff --git a/README.md b/README.md index 3641fae..8a221ab 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ 用于本地开发、协议研究和服务端实现练习。服务默认只监听环回地址,不应暴露到公网。 仓库根目录的 `versions.json` 是服务端与客户端插件共用的版本配置: -`client_version` 用于维护/启动响应,`protocol_version` 约束 seed 与存档, +`client_version` 用于维护/启动响应,并约束当前 seed 与存档, `game_data_version` 和 `bundle_version` 分别选择 GameData 与 ServerData, `seed_directory` 相对该配置文件解析。开发时服务端会从可执行文件目录或当前目录向上查找; 也可使用 `serve --version-config <文件>` 或环境变量 `BD2_VERSION_CONFIG` 显式指定。 -`plugins.local_identity` 与 `plugins.capture_environment` 是两个 BepInEx 插件各自的程序集版本。 +`plugins.local_identity`、`plugins.login_ui` 与 `plugins.capture_environment` 是三个 BepInEx 插件各自的版本。 插件构建会从仓库根配置生成临时 C# 常量,不在源码中重复版本值。 ## 许可与免责声明 @@ -24,59 +24,129 @@ Push-Location .\go $env:GOCACHE = (Join-Path $PWD '.cache\go-build') go test ./... go build .\cmd\bd2server +go build .\cmd\bd2client Pop-Location $env:PYTHONDONTWRITEBYTECODE = '1' python -m unittest discover -s .\tools\python\tests -p 'test_*.py' -v ``` +开发时无需先整理发布目录。在两个终端中分别从 `go` 目录运行: + +```powershell +$env:GOCACHE = (Join-Path $PWD '.cache\go-build') +go run .\cmd\bd2server --dev run +``` + +```powershell +$env:GOCACHE = (Join-Path $PWD '.cache\go-build') +go run .\cmd\bd2client --dev run +``` + +服务端开发入口会自动使用仓库根目录的版本、认证、资源、数据和存档配置;客户端开发入口会直接使用仓库版本清单和插件产物,并在已有游戏目录时先增量构建两个客户端插件。仍可在 `run` 后传入普通参数覆盖默认值,例如 `--listen` 或 `--game-dir`。 + +`--dev run` 由仅限非发布构建的 Go 文件提供。发布脚本统一使用 `-tags release` 编译,最终 `bd2client` 与 `bd2server` 不包含该入口,传入 `--dev` 会按未知参数或未知命令拒绝。 + ## 发布包 ```powershell .\build-release.ps1 -GameDir "<客户端目录>" ``` -脚本生成 `.build\bd2server-windows-x64.zip`。解压后得到一个完整目录,其中包含 Go 服务端、本地身份插件、`versions.json`、运行种子和空存档目录。发布包中的配置位于 `bd2server.exe` 同目录,因此不依赖启动工作目录。Go 构建缓存只写入 `go\.cache`。 +脚本分别生成 `.build\bd2server-windows-x64.zip` 和 `.build\bd2client-windows-x64.zip`。纯服务端包只包含 `bd2server.exe`、seed、认证/资源策略和服务端数据目录,不包含客户端 DLL、游戏补丁或客户端目录;客户端包只包含 `bd2client.exe`、客户端插件和版本文件,不包含服务端存档或 secret。Go 构建缓存只写入 `go\.cache`。 ## 客户端插件项目 - `plugins/LocalIdentity/`:本地服务端客户端专用插件。 +- `plugins/LoginUI/`:本地服 Discord/Google 登录界面插件。 - `plugins/CaptureEnvironment/`:独立原版对照客户端的抓包插件;不要装进本地服务端客户端。 +## 可视化客户端工具 + +`bd2client.exe` 是独立的客户端设置工具,不会启动或依赖服务端进程。双击后会优先以 Microsoft Edge 应用窗口打开内嵌界面;系统没有 Edge 时回退到默认浏览器。它提供以下彼此独立的操作: + +- 选择并验证 Brown Dust II 安装目录。 +- 填写服务器 origin 和端口,并写入 `BepInEx/config/bd2.client.json`。 +- 应用带原文件备份的客户端入口补丁。 +- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。 +- 选择官方 CDN、服务器资源源或本地已下载资源;服务器资源源统一涵盖服主自建与反代,并通过 `PUT /client/resources` 公开接口预检资源 URL 和版本。 + +客户端配置保存服务器地址、资源模式及本地模式所需的客户端路径,不保存 OAuth secret、服主 master key 或玩家登录凭据。朋友小服或通过 FRP 联机时建议选择官方 CDN,避免让资源下载占用服主与隧道带宽。服主启用服务器资源源时,客户端插件会在运行时从该服读取资源 URL 和版本;本地资源路径不会发送到服务端。 + +服主同步官方资源、自建静态 CDN 或配置缓存反代的步骤见 [服主资源与 CDN 配置指南](docs/RESOURCES.md)。 + +开发构建后可直接运行: + +```powershell +.\go\bd2client.exe +``` + ## 首次安装本地客户端 先手动安装 BepInEx:。然后修改客户端入口: ```powershell -$clientDir = "<客户端目录>" - -.\.build\package\bd2server\bd2server.exe patch-client ` - --game-dir $clientDir - +& ".\.build\package\bd2client\bd2client.exe" ``` -启动服务端时会检查 BepInEx,并自动从发布目录安装或更新 `BD2LocalIdentity.dll`。如果未检测到 BepInEx,服务端只提示官方下载链接,不修改客户端,也不会继续启动。不要把原版抓包插件 `BD2CaptureEnvironment.dll` 安装进这个客户端。 +客户端工具会选择游戏目录、写入服务器 origin 和 CDN 模式、执行入口补丁,并安装或更新 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。服务端不会检查 BepInEx,也不会读取或修改客户端目录。不要把原版抓包插件 `BD2CaptureEnvironment.dll` 安装进这个客户端。 ## 启动顺序 顺序必须是: 1. 确认客户端已经安装 BepInEx。 -2. 启动服务器;服务器自动同步 `BD2LocalIdentity.dll`。 -3. 健康检查返回成功后,再启动游戏客户端。 +2. 启动纯服务端;服务端只准备自己的 GameData 和玩家存档。 +3. 运行 `bd2client.exe` 完成客户端配置和插件安装。 +4. 健康检查返回成功后,再启动游戏客户端。 启动服务器: ```powershell -Push-Location .\.build\package\bd2server\go -& ..\bd2server.exe serve ` - --game-dir "<客户端目录>" ` - --cdn "" ` - --game-data "" -Pop-Location +& ".\.build\package\bd2server\bd2server.exe" serve ``` -启动时先检查 BepInEx 并同步插件,再核验资源;随后打开唯一的 `state.db`。数据库以明确的 `schema_version` 管理格式,Go 迁移器只允许按 `N→N+1` 顺序升级,并在同一个 SQLite 事务里完成全部迁移、版本写入和最终状态校验;任一步失败都会整体回滚。领域状态完成加载或首次初始化后,还会在启动事务提交前再次执行 Go 状态校验,通过后才监听指定地址(默认 `127.0.0.1:8080`)。本地身份插件目前固定连接该默认地址;要更换地址,需要同步修改插件。 +启动时会准备服务端逻辑所需 GameData,随后打开玩家 SQLite 存档。数据库以明确的 `schema_version` 管理格式,Go 迁移器只允许按 `N→N+1` 顺序升级,并在同一个 SQLite 事务里完成全部迁移、版本写入和最终状态校验;任一步失败都会整体回滚。服务端不需要客户端、ServerData 或 Windows 游戏目录。客户端由 `bd2client.exe` 单独配置。 + +认证策略由 `bd2server.exe` 同目录的 `authentication.json` 权威决定。`mode=local`(默认)不依赖第三方登录;公网或联机服可设为 `oauth`。登录界面插件从当前连接的服务器读取公开策略,只显示服务器允许的 Discord/Google 入口,客户端不能自行打开未启用的 provider。服主创建 OAuth 应用、取得 client ID/secret、配置回调地址和安全注入环境变量的完整步骤见 [服主第三方登录配置指南](docs/AUTHENTICATION.md)。 + +OAuth 模式示例: + +```json +{ + "mode": "oauth", + "public_url": "https://bd2.example.com", + "master_key_env": "BD2_AUTH_MASTER_KEY", + "providers": { + "discord": { + "client_id": "Discord application client ID", + "client_secret_env": "BD2_DISCORD_CLIENT_SECRET" + }, + "google": { + "client_id": "Google OAuth client ID", + "client_secret_env": "BD2_GOOGLE_CLIENT_SECRET" + } + }, + "session": { + "access_ttl": "15m", + "refresh_ttl": "720h", + "device_transaction_ttl": "10m" + } +} +``` + +`public_url` 是客户端、浏览器和 OAuth provider 都能访问的固定 origin;公网地址必须使用 HTTPS,只有 loopback 开发地址允许 HTTP。OAuth 模式下服务端从它派生 `/game/` 地址;ServerData 与 GameData 地址由独立的 `resources.json` 决定。反向代理或内网穿透必须转发 `/auth/`、`/game/`、`/client/resources` 和健康检查;静态资源是否经过服主入口取决于所选 CDN 模式。provider 控制台的 redirect URI 必须分别注册为: + +```text +https://bd2.example.com/auth/discord/callback +https://bd2.example.com/auth/google/callback +``` + +`authentication.json` 只保存 client ID 和环境变量名。master key 必须是 Base64 编码的 32 个随机字节;Discord/Google client secret 与 master key 的实际值只通过配置所指向的环境变量注入。master key 必须长期稳定并单独备份:更换或丢失它会使现有 `auth.db` 的身份摘要、token 摘要和待领取密文失效。服务端不会把 secret、第三方 token或游戏 token写入配置或日志。 + +认证数据位于玩家 `state.db` 同目录的独立 `auth.db`。access token、refresh token、device secret、OAuth state 和 provider subject 只保存带用途隔离的 HMAC-SHA-256;必须临时恢复的 PKCE verifier、OIDC nonce 和待领取登录结果使用 AES-256-GCM。provider token 只在回调内存中使用,不落盘。客户端 OAuth access token 只在进程内存中;Windows refresh credential 由当前用户 DPAPI 加密后以密文写入 PlayerPrefs 注册表,macOS 写入 Keychain Services,并按规范化服务器 origin 隔离。其他平台禁用自动登录,不会退化成明文存储。 + +当前版本正在先完成纯服务端与客户端工具分离;领域状态仍按现有单存档运行,真正的多玩家独立存档会在后续按 `account_id` 拆分为每账号 SQLite runtime,不会通过共享一份 `state.db` 冒充多账号。 健康检查: @@ -86,11 +156,4 @@ Invoke-WebRequest http://127.0.0.1:8080/healthz 健康检查成功后才能启动本地客户端。服务器尚未监听时启动客户端,会在登录或维护信息阶段连接失败。 -## 修改客户端 - -构建服务后运行: - -```powershell -.\.build\package\bd2server\bd2server.exe patch-client --game-dir "<客户端目录>" --verify -.\.build\package\bd2server\bd2server.exe patch-client --game-dir "<客户端目录>" -``` +客户端入口补丁和插件安装由独立的 `bd2client.exe` 完成,服务端不再提供 `patch-client` 子命令。 diff --git a/RELEASE.md b/RELEASE.md index ad59eee..6c77b20 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,34 +1,35 @@ -# BD2 本地服务器发布包 +# BD2 纯服务端发布包 ## 首次使用 -1. 为游戏手动安装 BepInEx。官方下载: - -2. 修改本地客户端入口: +1. 准备服务端数据目录。服务端不需要 Windows 游戏客户端、BepInEx 或客户端插件。 +2. 编辑同目录的 `authentication.json`、`resources.json`;OAuth 配置见 `AUTHENTICATION.md`。 +3. 启动服务器: + + Windows PowerShell: ```powershell - .\bd2server.exe patch-client --game-dir "<客户端目录>" + .\bd2server.exe serve ``` -3. 进入发布包的 `go` 目录并启动服务器: + macOS/Linux(Bash 或 Zsh): - ```powershell - Push-Location .\go - & ..\bd2server.exe serve ` - --game-dir "<客户端目录>" ` - --cdn "" ` - --game-data "" - Pop-Location + ```bash + ./bd2server serve ``` -服务器会检查 BepInEx,并自动把包内 `plugins\BD2LocalIdentity.dll` 安装或更新到客户端。如果没有安装 BepInEx,服务器会给出上述下载地址、保持客户端不变并拒绝启动。 +服务端默认使用可执行文件旁的 `data` 目录,玩家存档位于 `data/state/state.db`,服务端逻辑所需 GameData 位于 `data/resources/GameData`。缺失时会从官方源下载并完整校验。服务端不会访问或修改玩家客户端目录。 4. 确认健康检查成功: - ```powershell - Invoke-WebRequest http://127.0.0.1:8080/healthz + ```bash + curl --fail --silent --show-error http://127.0.0.1:8080/healthz ``` -5. 最后启动游戏客户端。请勿在服务器启动前运行客户端。 +5. 让玩家使用独立的 `bd2client.exe` 选择游戏目录、填写服务器地址、选择 CDN,并安装 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。客户端工具说明见 `README.md`(客户端包)或仓库的 `docs/CLIENT.md`。 -`versions.json` 是服务端和插件的统一版本选择,必须与 `bd2server.exe` 保持在同一目录;服务端从该位置解析包内 `go/seed`,不依赖当前工作目录。需要临时测试另一组版本时可传 `serve --version-config <文件>`。玩家存档位于 `data\state\state.db`;服务端以 Go 迁移器按相邻版本升级,并在同一个 SQLite 事务内完成迁移和最终校验,失败时不会留下部分升级。 +`versions.json` 是服务端和客户端插件共用的版本选择,必须与各自可执行文件保持在同一目录。服务端从包内 `go/seed` 读取种子,不依赖当前工作目录。需要临时测试另一组版本时可传 `serve --version-config <文件>`。玩家存档位于 `data\state\state.db`;服务端以 Go 迁移器按相邻版本升级,并在同一个 SQLite 事务内完成迁移和最终校验,失败时不会留下部分升级。 + +`authentication.json` 和 `resources.json` 必须与 `bd2server.exe` 保持在同一目录。默认 `local` 认证模式继续自动本地登录;公网或联机服可以启用 OAuth,并把资源策略设为官方 CDN 或统一的服务器资源源。服主自建与反代使用相同的 `server` 模式;玩家本地资源目录仅由 `bd2client.exe` 配置。逐步配置方法见发布包内的 [服主第三方登录配置指南](AUTHENTICATION.md) 和 [服主资源与 CDN 配置指南](RESOURCES.md)。 + +OAuth access token 只在客户端内存中;Windows refresh credential 使用当前用户 DPAPI 加密后写入 PlayerPrefs 注册表,macOS 使用 Keychain Services。服务端 `auth.db` 只保存 token、device secret、OAuth state 及 provider subject 的 HMAC,临时可恢复材料使用 AES-256-GCM。 diff --git a/authentication.json b/authentication.json new file mode 100644 index 0000000..b971679 --- /dev/null +++ b/authentication.json @@ -0,0 +1,4 @@ +{ + "mode": "local", + "providers": {} +} diff --git a/build-release.ps1 b/build-release.ps1 index 8b0bf80..6e12aa7 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -2,32 +2,41 @@ param( [Parameter(Mandatory)] [string]$GameDir, - [switch]$SkipTests ) $ErrorActionPreference = 'Stop' $root = $PSScriptRoot $goRoot = Join-Path $root 'go' -$localIdentityProject = Join-Path $root 'plugins\LocalIdentity\LocalIdentity.csproj' $buildRoot = Join-Path $root '.build' $packageParent = Join-Path $buildRoot 'package' -$packageDir = Join-Path $packageParent 'bd2server' -$packagePluginDir = Join-Path $packageDir 'plugins' -$packageGoDir = Join-Path $packageDir 'go' -$packageStateDir = Join-Path $packageDir 'data\state' -$archive = Join-Path $buildRoot 'bd2server-windows-x64.zip' +$serverPackage = Join-Path $packageParent 'bd2server' +$clientPackage = Join-Path $packageParent 'bd2client' +$macAMD64Package = Join-Path $packageParent 'bd2client-macos-amd64' +$macARM64Package = Join-Path $packageParent 'bd2client-macos-arm64' +$serverGoDir = Join-Path $serverPackage 'go' +$serverStateDir = Join-Path $serverPackage 'data\state' +$clientPluginDir = Join-Path $clientPackage 'plugins' +$macAMD64ExecutableDir = Join-Path $macAMD64Package 'BD2 Client Studio.app\Contents\MacOS' +$macARM64ExecutableDir = Join-Path $macARM64Package 'BD2 Client Studio.app\Contents\MacOS' +$serverArchive = Join-Path $buildRoot 'bd2server-windows-x64.zip' +$clientArchive = Join-Path $buildRoot 'bd2client-windows-x64.zip' +$macAMD64Archive = Join-Path $buildRoot 'bd2client-macos-amd64.zip' +$macARM64Archive = Join-Path $buildRoot 'bd2client-macos-arm64.zip' $versionConfig = Join-Path $root 'versions.json' +$authenticationConfig = Join-Path $root 'authentication.json' +$resourceConfig = Join-Path $root 'resources.json' $GameDir = [IO.Path]::GetFullPath($GameDir) -if (-not (Test-Path -LiteralPath (Join-Path $GameDir 'BrownDust II.exe') -PathType Leaf)) { - throw "GameDir does not contain BrownDust II.exe: $GameDir" +$gameExecutable = Join-Path $GameDir 'BrownDust II.exe' +if (-not [IO.File]::Exists($gameExecutable)) { + throw "GameDir does not contain Brown Dust II.exe: $GameDir" } $env:GOCACHE = Join-Path $goRoot '.cache\go-build' New-Item -ItemType Directory -Force -Path $env:GOCACHE, $buildRoot | Out-Null -foreach ($target in @($packageParent, $archive)) { +foreach ($target in @($packageParent, $serverArchive, $clientArchive, $macAMD64Archive, $macARM64Archive)) { if (Test-Path -LiteralPath $target) { $resolved = [IO.Path]::GetFullPath($target) $expectedRoot = [IO.Path]::GetFullPath($buildRoot) + [IO.Path]::DirectorySeparatorChar @@ -37,15 +46,7 @@ foreach ($target in @($packageParent, $archive)) { Remove-Item -LiteralPath $resolved -Recurse -Force } } -New-Item -ItemType Directory -Force -Path $packageDir, $packagePluginDir, $packageGoDir, $packageStateDir | Out-Null - -dotnet build $localIdentityProject -c Release "-p:GameDir=$GameDir" --nologo -if ($LASTEXITCODE -ne 0) { throw "LocalIdentity build failed with exit code $LASTEXITCODE" } -$identityPlugin = Join-Path $root 'plugins\LocalIdentity\bin\Release\netstandard2.1\BD2LocalIdentity.dll' -if (-not (Test-Path -LiteralPath $identityPlugin -PathType Leaf)) { - throw "LocalIdentity build output is missing: $identityPlugin" -} -Copy-Item -LiteralPath $identityPlugin -Destination (Join-Path $packagePluginDir 'BD2LocalIdentity.dll') -Force +New-Item -ItemType Directory -Force -Path $serverPackage, $serverGoDir, $serverStateDir, $clientPackage, $clientPluginDir, $macAMD64ExecutableDir, $macARM64ExecutableDir | Out-Null Push-Location $goRoot try { @@ -54,16 +55,112 @@ try { if ($LASTEXITCODE -ne 0) { throw "go test failed with exit code $LASTEXITCODE" } go vet ./... if ($LASTEXITCODE -ne 0) { throw "go vet failed with exit code $LASTEXITCODE" } + go test -tags release ./... + if ($LASTEXITCODE -ne 0) { throw "release-tag go test failed with exit code $LASTEXITCODE" } + go vet -tags release ./... + if ($LASTEXITCODE -ne 0) { throw "release-tag go vet failed with exit code $LASTEXITCODE" } + } + go build -tags release -trimpath -ldflags '-s -w' -o (Join-Path $serverPackage 'bd2server.exe') .\cmd\bd2server + if ($LASTEXITCODE -ne 0) { throw "bd2server build failed with exit code $LASTEXITCODE" } + go build -tags release -trimpath -ldflags '-H windowsgui -s -w' -o (Join-Path $clientPackage 'bd2client.exe') .\cmd\bd2client + if ($LASTEXITCODE -ne 0) { throw "bd2client build failed with exit code $LASTEXITCODE" } + $savedGOOS = $env:GOOS + $savedGOARCH = $env:GOARCH + try { + $env:GOOS = 'darwin' + $env:GOARCH = 'amd64' + go build -tags release -trimpath -ldflags '-s -w' -o (Join-Path $macAMD64ExecutableDir 'bd2client') .\cmd\bd2client + if ($LASTEXITCODE -ne 0) { throw "bd2client macOS amd64 build failed with exit code $LASTEXITCODE" } + $env:GOARCH = 'arm64' + go build -tags release -trimpath -ldflags '-s -w' -o (Join-Path $macARM64ExecutableDir 'bd2client') .\cmd\bd2client + if ($LASTEXITCODE -ne 0) { throw "bd2client macOS arm64 build failed with exit code $LASTEXITCODE" } + } finally { + $env:GOOS = $savedGOOS + $env:GOARCH = $savedGOARCH } - go build -trimpath -ldflags '-s -w' -o (Join-Path $packageDir 'bd2server.exe') .\cmd\bd2server - if ($LASTEXITCODE -ne 0) { throw "go build failed with exit code $LASTEXITCODE" } } finally { Pop-Location } -Copy-Item -LiteralPath (Join-Path $goRoot 'seed') -Destination (Join-Path $packageGoDir 'seed') -Recurse -Force -Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $packageDir 'versions.json') -Force -Copy-Item -LiteralPath (Join-Path $root 'RELEASE.md') -Destination (Join-Path $packageDir 'README.md') -Force -Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $packageDir 'LICENSE') -Force +$clientPlugins = @( + @{ Name = 'LocalIdentity'; Project = Join-Path $root 'plugins\LocalIdentity\LocalIdentity.csproj'; Output = Join-Path $root 'plugins\LocalIdentity\bin\Release\netstandard2.1\BD2LocalIdentity.dll'; FileName = 'BD2LocalIdentity.dll' }, + @{ Name = 'LoginUI'; Project = Join-Path $root 'plugins\LoginUI\LoginUI.csproj'; Output = Join-Path $root 'plugins\LoginUI\bin\Release\netstandard2.1\BD2LoginUI.dll'; FileName = 'BD2LoginUI.dll' } +) +foreach ($plugin in $clientPlugins) { + dotnet build $plugin.Project -c Release "-p:GameDir=$GameDir" --nologo + if ($LASTEXITCODE -ne 0) { throw "$($plugin.Name) build failed with exit code $LASTEXITCODE" } + if (-not (Test-Path -LiteralPath $plugin.Output -PathType Leaf)) { throw "$($plugin.Name) build output is missing" } + Copy-Item -LiteralPath $plugin.Output -Destination (Join-Path $clientPluginDir $plugin.FileName) -Force + foreach ($macExecutableDir in @($macAMD64ExecutableDir, $macARM64ExecutableDir)) { + $macPluginDir = Join-Path $macExecutableDir 'plugins' + New-Item -ItemType Directory -Force -Path $macPluginDir | Out-Null + Copy-Item -LiteralPath $plugin.Output -Destination (Join-Path $macPluginDir $plugin.FileName) -Force + } +} -Compress-Archive -LiteralPath $packageDir -DestinationPath $archive -CompressionLevel Optimal -Write-Host "Built release directory: $packageDir" -Write-Host "Built release archive: $archive" +Copy-Item -LiteralPath (Join-Path $goRoot 'seed') -Destination $serverGoDir -Recurse -Force +Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $serverPackage 'versions.json') -Force +Copy-Item -LiteralPath $authenticationConfig -Destination (Join-Path $serverPackage 'authentication.json') -Force +Copy-Item -LiteralPath $resourceConfig -Destination (Join-Path $serverPackage 'resources.json') -Force +Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $clientPackage 'versions.json') -Force +foreach ($macExecutableDir in @($macAMD64ExecutableDir, $macARM64ExecutableDir)) { + Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $macExecutableDir 'versions.json') -Force +} +Copy-Item -LiteralPath (Join-Path $root 'RELEASE.md') -Destination (Join-Path $serverPackage 'README.md') -Force +Copy-Item -LiteralPath (Join-Path $root 'docs\AUTHENTICATION.md') -Destination (Join-Path $serverPackage 'AUTHENTICATION.md') -Force +Copy-Item -LiteralPath (Join-Path $root 'docs\RESOURCES.md') -Destination (Join-Path $serverPackage 'RESOURCES.md') -Force +Copy-Item -LiteralPath (Join-Path $root 'docs\CLIENT.md') -Destination (Join-Path $clientPackage 'README.md') -Force +Copy-Item -LiteralPath (Join-Path $root 'docs\CLIENT.md') -Destination (Join-Path $macAMD64Package 'README.md') -Force +Copy-Item -LiteralPath (Join-Path $root 'docs\CLIENT.md') -Destination (Join-Path $macARM64Package 'README.md') -Force +Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $serverPackage 'LICENSE') -Force +Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $clientPackage 'LICENSE') -Force +Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $macAMD64Package 'LICENSE') -Force +Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $macARM64Package 'LICENSE') -Force + +$infoPlist = @' + + + +CFBundleDevelopmentRegionen +CFBundleDisplayNameBD2 Client Studio +CFBundleExecutablebd2client +CFBundleIdentifiercc.sighs.bd2.clientstudio +CFBundleInfoDictionaryVersion6.0 +CFBundleNameBD2 Client Studio +CFBundlePackageTypeAPPL +CFBundleShortVersionString1.0.0 +LSMinimumSystemVersion11.0 + +'@ +foreach ($macPackage in @($macAMD64Package, $macARM64Package)) { + $plistPath = Join-Path $macPackage 'BD2 Client Studio.app\Contents\Info.plist' + [IO.File]::WriteAllText($plistPath, $infoPlist, [Text.UTF8Encoding]::new($false)) +} + +function Compress-MacApp { + param([string]$Source, [string]$Destination) + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::Open($Destination, [IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($file in Get-ChildItem -LiteralPath $Source -File -Recurse) { + $relative = [IO.Path]::GetRelativePath($Source, $file.FullName).Replace('\', '/') + $entry = [IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $file.FullName, $relative, [IO.Compression.CompressionLevel]::Optimal) + if ($relative.EndsWith('/Contents/MacOS/bd2client', [StringComparison]::Ordinal) -or $relative -eq 'BD2 Client Studio.app/Contents/MacOS/bd2client') { + $entry.ExternalAttributes = -2115174400 # Unix regular file mode 0755. + } else { + $entry.ExternalAttributes = -2119958528 # Unix regular file mode 0644. + } + } + } finally { + $archive.Dispose() + } +} + +Compress-Archive -LiteralPath $serverPackage -DestinationPath $serverArchive -CompressionLevel Optimal +Compress-Archive -LiteralPath $clientPackage -DestinationPath $clientArchive -CompressionLevel Optimal +Compress-MacApp -Source $macAMD64Package -Destination $macAMD64Archive +Compress-MacApp -Source $macARM64Package -Destination $macARM64Archive +Write-Host "Built pure server directory: $serverPackage" +Write-Host "Built pure server archive: $serverArchive" +Write-Host "Built client directory: $clientPackage" +Write-Host "Built client archive: $clientArchive" +Write-Host "Built macOS amd64 client: $macAMD64Archive" +Write-Host "Built macOS arm64 client: $macARM64Archive" diff --git a/go/cmd/bd2client/dev_command.go b/go/cmd/bd2client/dev_command.go new file mode 100644 index 0000000..f1f452e --- /dev/null +++ b/go/cmd/bd2client/dev_command.go @@ -0,0 +1,128 @@ +//go:build !release + +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + clientconfig "bd2server/internal/client/config" + clientlayout "bd2server/internal/client/layout" +) + +func developmentRunOptions(args []string) ([]string, clientRunOptions, error) { + if len(args) == 0 || args[0] != "--dev" { + return args, clientRunOptions{}, nil + } + if len(args) < 2 || args[1] != "run" { + return nil, clientRunOptions{}, errors.New("usage: bd2client --dev run [client options]") + } + root, err := findClientDevelopmentRoot() + if err != nil { + return nil, clientRunOptions{}, err + } + clientArgs := append([]string(nil), args[2:]...) + gameDir, err := clientDevelopmentGameDirectory(clientArgs) + if err != nil { + return nil, clientRunOptions{}, err + } + if gameDir == "" { + preferences, preferenceErr := clientconfig.LoadPreferences() + if preferenceErr == nil { + gameDir = preferences.GameDirectory + } + } + if gameDir != "" { + if err := buildDevelopmentPlugins(root, gameDir); err != nil { + return nil, clientRunOptions{}, err + } + } + return clientArgs, clientRunOptions{ + versionConfigPath: filepath.Join(root, "versions.json"), + logExecutablePath: filepath.Join(root, "data", "bd2client-dev"), + localIdentityPlugin: filepath.Join(root, "plugins", "LocalIdentity", "bin", "Release", "netstandard2.1", "BD2LocalIdentity.dll"), + loginUIPlugin: filepath.Join(root, "plugins", "LoginUI", "bin", "Release", "netstandard2.1", "BD2LoginUI.dll"), + }, nil +} + +func findClientDevelopmentRoot() (string, error) { + working, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve development working directory: %w", err) + } + for directory := filepath.Clean(working); ; directory = filepath.Dir(directory) { + if clientDevelopmentFile(filepath.Join(directory, "versions.json")) && + clientDevelopmentFile(filepath.Join(directory, "go", "go.mod")) && + clientDevelopmentFile(filepath.Join(directory, "plugins", "LocalIdentity", "LocalIdentity.csproj")) && + clientDevelopmentFile(filepath.Join(directory, "plugins", "LoginUI", "LoginUI.csproj")) { + return directory, nil + } + parent := filepath.Dir(directory) + if parent == directory { + break + } + } + return "", errors.New("development repository root not found; run from the bd2 repository") +} + +func clientDevelopmentFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func clientDevelopmentGameDirectory(args []string) (string, error) { + for index := 0; index < len(args); index++ { + arg := args[index] + if arg == "--game-dir" { + if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" { + return "", errors.New("--game-dir requires a directory") + } + return filepath.Clean(args[index+1]), nil + } + if strings.HasPrefix(arg, "--game-dir=") { + value := strings.TrimSpace(strings.TrimPrefix(arg, "--game-dir=")) + if value == "" { + return "", errors.New("--game-dir requires a directory") + } + return filepath.Clean(value), nil + } + } + return "", nil +} + +func buildDevelopmentPlugins(root, gameDir string) error { + installation, err := clientlayout.Resolve(gameDir) + if err != nil { + return nil + } + if !clientDevelopmentFile(filepath.Join(installation.BepInEx, "core", "BepInEx.dll")) { + return nil + } + projects := []string{ + filepath.Join(root, "plugins", "LocalIdentity", "LocalIdentity.csproj"), + filepath.Join(root, "plugins", "LoginUI", "LoginUI.csproj"), + } + for _, project := range projects { + command := exec.Command( + "dotnet", "build", project, "-c", "Release", + "-p:GameDir="+filepath.Clean(gameDir), + "-p:BD2ManagedDir="+filepath.Join(installation.Data, "Managed"), + "-p:BD2BepInExDir="+installation.BepInEx, + "--nologo", + ) + command.Dir = root + output, err := command.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return fmt.Errorf("build development plugin %s: %s", filepath.Base(project), message) + } + } + return nil +} diff --git a/go/cmd/bd2client/dev_command_release.go b/go/cmd/bd2client/dev_command_release.go new file mode 100644 index 0000000..5f9e45a --- /dev/null +++ b/go/cmd/bd2client/dev_command_release.go @@ -0,0 +1,7 @@ +//go:build release + +package main + +func developmentRunOptions(args []string) ([]string, clientRunOptions, error) { + return args, clientRunOptions{}, nil +} diff --git a/go/cmd/bd2client/dev_command_release_test.go b/go/cmd/bd2client/dev_command_release_test.go new file mode 100644 index 0000000..d917de9 --- /dev/null +++ b/go/cmd/bd2client/dev_command_release_test.go @@ -0,0 +1,15 @@ +//go:build release + +package main + +import "testing" + +func TestReleaseBuildRejectsDevelopmentFlag(t *testing.T) { + args, options, err := developmentRunOptions([]string{"--dev", "run"}) + if err != nil { + t.Fatal(err) + } + if err := runClient(args, options); err == nil { + t.Fatal("release build unexpectedly accepted --dev run") + } +} diff --git a/go/cmd/bd2client/dev_command_test.go b/go/cmd/bd2client/dev_command_test.go new file mode 100644 index 0000000..bb39f21 --- /dev/null +++ b/go/cmd/bd2client/dev_command_test.go @@ -0,0 +1,66 @@ +//go:build !release + +package main + +import ( + "path/filepath" + "testing" +) + +func TestClientDevelopmentGameDirectory(t *testing.T) { + for _, test := range []struct { + name string + args []string + want string + }{ + {name: "separate", args: []string{"--game-dir", filepath.Join("some", "game")}, want: filepath.Join("some", "game")}, + {name: "equals", args: []string{"--game-dir=" + filepath.Join("other", "game")}, want: filepath.Join("other", "game")}, + {name: "absent", args: []string{"--no-browser"}}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := clientDevelopmentGameDirectory(test.args) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("game directory = %q, want %q", got, test.want) + } + }) + } +} + +func TestClientDevelopmentGameDirectoryRequiresValue(t *testing.T) { + for _, args := range [][]string{{"--game-dir"}, {"--game-dir="}} { + if _, err := clientDevelopmentGameDirectory(args); err == nil { + t.Fatalf("args %v unexpectedly succeeded", args) + } + } +} + +func TestDevelopmentRunOptionsUsesRepositoryFiles(t *testing.T) { + t.Setenv("APPDATA", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + args, options, err := developmentRunOptions([]string{"--dev", "run", "--no-browser"}) + if err != nil { + t.Fatal(err) + } + if len(args) != 1 || args[0] != "--no-browser" { + t.Fatalf("client args = %v", args) + } + for name, path := range map[string]string{ + "versions": options.versionConfigPath, + "log executable": options.logExecutablePath, + "local identity": options.localIdentityPlugin, + "login UI": options.loginUIPlugin, + } { + if !filepath.IsAbs(path) { + t.Errorf("%s path is not absolute: %q", name, path) + } + } +} + +func TestDevelopmentRunOptionsRequiresRun(t *testing.T) { + if _, _, err := developmentRunOptions([]string{"--dev"}); err == nil { + t.Fatal("development command without run unexpectedly succeeded") + } +} diff --git a/go/cmd/bd2client/main.go b/go/cmd/bd2client/main.go new file mode 100644 index 0000000..834d5f4 --- /dev/null +++ b/go/cmd/bd2client/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "flag" + "fmt" + "os" + + clientapp "bd2server/internal/client/app" + clientconfig "bd2server/internal/client/config" +) + +type clientRunOptions struct { + versionConfigPath string + logExecutablePath string + localIdentityPlugin string + loginUIPlugin string +} + +func main() { + args, options, err := developmentRunOptions(os.Args[1:]) + if err == nil { + err = runClient(args, options) + } + if err != nil { + clientapp.ShowFatalError(err) + fmt.Fprintln(os.Stderr, "bd2client:", err) + os.Exit(1) + } +} + +func runClient(args []string, options clientRunOptions) error { + fs := flag.NewFlagSet("bd2client", flag.ContinueOnError) + listen := fs.String("listen", "127.0.0.1:0", "loopback address for the local setup interface") + noBrowser := fs.Bool("no-browser", false, "print the interface URL without opening a browser") + gameDir := fs.String("game-dir", "", "initial Brown Dust II installation directory") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + + logger, logCloser, logPath, err := clientapp.OpenPersistentLogger(options.logExecutablePath) + if err != nil { + return err + } + defer logCloser.Close() + logger.Info("bd2client starting", "log_path", logPath) + + var versions clientconfig.ReleaseVersions + if options.versionConfigPath == "" { + versions, err = clientconfig.ReleaseVersionsBesideExecutable() + } else { + versions, err = clientconfig.LoadReleaseVersions(options.versionConfigPath) + } + if err != nil { + logger.Error("client release version manifest is invalid", "error", err) + return err + } + if *gameDir == "" { + preferences, preferenceErr := clientconfig.LoadPreferences() + if preferenceErr != nil { + logger.Warn("could not load client preferences", "error", preferenceErr) + } else { + *gameDir = preferences.GameDirectory + } + } + if err := clientapp.Run(clientapp.Options{ + Listen: *listen, + NoBrowser: *noBrowser, + InitialGameDir: *gameDir, + Logger: logger, + LogPath: logPath, + Versions: versions, + LocalIdentityPlugin: options.localIdentityPlugin, + LoginUIPlugin: options.loginUIPlugin, + }); err != nil { + logger.Error("bd2client stopped with an error", "error", err) + return err + } + logger.Info("bd2client exited") + return nil +} diff --git a/go/cmd/bd2server/dev_command.go b/go/cmd/bd2server/dev_command.go new file mode 100644 index 0000000..3e3ec58 --- /dev/null +++ b/go/cmd/bd2server/dev_command.go @@ -0,0 +1,72 @@ +//go:build !release + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func runDevelopmentCommand(args []string) (bool, error) { + if len(args) == 0 || args[0] != "--dev" { + return false, nil + } + if len(args) < 2 || args[1] != "run" { + return true, errors.New("usage: bd2server --dev run [serve options]") + } + root, err := findDevelopmentRoot() + if err != nil { + return true, err + } + serveArgs := append([]string(nil), args[2:]...) + serveArgs = appendDefaultFlag(serveArgs, "--version-config", filepath.Join(root, "versions.json")) + serveArgs = appendDefaultFlag(serveArgs, "--authentication-config", filepath.Join(root, "authentication.json")) + serveArgs = appendDefaultFlag(serveArgs, "--resource-config", filepath.Join(root, "resources.json")) + serveArgs = appendDefaultFlag(serveArgs, "--data-dir", filepath.Join(root, "data")) + serveArgs = appendDefaultFlag(serveArgs, "--state", filepath.Join(root, "data", "state", "state.db")) + return true, serve(serveArgs) +} + +func findDevelopmentRoot() (string, error) { + working, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve development working directory: %w", err) + } + for directory := filepath.Clean(working); ; directory = filepath.Dir(directory) { + if regularDevelopmentFile(filepath.Join(directory, "versions.json")) && + regularDevelopmentFile(filepath.Join(directory, "go", "go.mod")) && + regularDevelopmentFile(filepath.Join(directory, "authentication.json")) && + regularDevelopmentFile(filepath.Join(directory, "resources.json")) { + return directory, nil + } + parent := filepath.Dir(directory) + if parent == directory { + break + } + } + return "", errors.New("development repository root not found; run from the bd2 repository") +} + +func regularDevelopmentFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func appendDefaultFlag(args []string, name, value string) []string { + for index, arg := range args { + if arg == name || strings.HasPrefix(arg, name+"=") { + return args + } + if index > 0 && args[index-1] == name { + return args + } + } + return append(args, name, value) +} + +func developmentUsage() string { + return "\n\nDevelopment build only:\n\tbd2server --dev run [serve options]" +} diff --git a/go/cmd/bd2server/dev_command_release.go b/go/cmd/bd2server/dev_command_release.go new file mode 100644 index 0000000..5404dc7 --- /dev/null +++ b/go/cmd/bd2server/dev_command_release.go @@ -0,0 +1,7 @@ +//go:build release + +package main + +func runDevelopmentCommand([]string) (bool, error) { return false, nil } + +func developmentUsage() string { return "" } diff --git a/go/cmd/bd2server/dev_command_release_test.go b/go/cmd/bd2server/dev_command_release_test.go new file mode 100644 index 0000000..4ff7930 --- /dev/null +++ b/go/cmd/bd2server/dev_command_release_test.go @@ -0,0 +1,15 @@ +//go:build release + +package main + +import "testing" + +func TestReleaseBuildDoesNotHandleDevelopmentCommand(t *testing.T) { + handled, err := runDevelopmentCommand([]string{"--dev", "run"}) + if err != nil { + t.Fatal(err) + } + if handled { + t.Fatal("release build unexpectedly handled --dev run") + } +} diff --git a/go/cmd/bd2server/dev_command_test.go b/go/cmd/bd2server/dev_command_test.go new file mode 100644 index 0000000..db3d5ad --- /dev/null +++ b/go/cmd/bd2server/dev_command_test.go @@ -0,0 +1,18 @@ +//go:build !release + +package main + +import "testing" + +func TestAppendDefaultFlagPreservesExplicitOverride(t *testing.T) { + for _, args := range [][]string{{"--data-dir", "custom"}, {"--data-dir=custom"}} { + got := appendDefaultFlag(append([]string(nil), args...), "--data-dir", "default") + if len(got) != len(args) { + t.Fatalf("args=%v got=%v", args, got) + } + } + got := appendDefaultFlag(nil, "--data-dir", "default") + if len(got) != 2 || got[0] != "--data-dir" || got[1] != "default" { + t.Fatalf("default args=%v", got) + } +} diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 1530ff3..edcc77b 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -9,32 +9,42 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" - "bd2server/internal/account" - "bd2server/internal/accountstate" - "bd2server/internal/battle" - "bd2server/internal/bootstrap" - "bd2server/internal/clientplugin" - "bd2server/internal/deck" - "bd2server/internal/feature" - "bd2server/internal/gacha" - "bd2server/internal/gamedata" - "bd2server/internal/introdb" - "bd2server/internal/mail" - "bd2server/internal/missions" - "bd2server/internal/pictorial" - "bd2server/internal/player" - "bd2server/internal/progress" - "bd2server/internal/readonly" - "bd2server/internal/schedule" - "bd2server/internal/session" - "bd2server/internal/transport" - "bd2server/internal/versionconfig" - "bd2server/internal/world" + "bd2server/internal/server/account" + "bd2server/internal/server/accountstate" + "bd2server/internal/server/auth" + "bd2server/internal/server/authconfig" + "bd2server/internal/server/battle" + "bd2server/internal/server/bootstrap" + "bd2server/internal/server/deck" + "bd2server/internal/server/feature" + "bd2server/internal/server/gacha" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/mail" + "bd2server/internal/server/missions" + "bd2server/internal/server/pictorial" + "bd2server/internal/server/player" + "bd2server/internal/server/progress" + "bd2server/internal/server/readonly" + "bd2server/internal/server/resourcefetch" + "bd2server/internal/server/resourcepolicy" + "bd2server/internal/server/schedule" + "bd2server/internal/server/session" + "bd2server/internal/server/transport" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/world" ) func main() { + if handled, err := runDevelopmentCommand(os.Args[1:]); handled { + if err != nil { + slog.Error("development command failed", "error", err) + os.Exit(1) + } + return + } if len(os.Args) < 2 { usage() os.Exit(2) @@ -43,10 +53,10 @@ func main() { switch os.Args[1] { case "serve": err = serve(os.Args[2:]) - case "patch-client": - err = patchClient(os.Args[2:]) case "state": err = stateCommand(os.Args[2:]) + case "resources": + err = resourcesCommand(os.Args[2:]) case "help", "-h", "--help": usage() return @@ -62,21 +72,20 @@ func main() { func serve(args []string) (serveErr error) { fs := flag.NewFlagSet("serve", flag.ContinueOnError) versionConfigPath := fs.String("version-config", "", "repository versions.json override") + authConfigPath := fs.String("authentication-config", "", "authentication.json override for development") + resourceConfigPath := fs.String("resource-config", "", "resources.json override for development") listen := fs.String("listen", "127.0.0.1:8080", "local listen address") - cdn := fs.String("cdn", "", "ServerData root (required)") - gameData := fs.String("game-data", "", "versioned GameData root (required)") + dataDir := fs.String("data-dir", "", "server data directory (defaults beside the executable)") gameDataVersion := fs.String("game-data-version", "", "validated GameData version (defaults to versions.json)") - gameDataOrigin := fs.String("game-data-origin", "https://dl.bd2.pmang.cloud/GameData", "official repair source used only when local validation fails") + gameDataOrigin := fs.String("game-data-origin", resourcepolicy.OfficialGameDataURL, "official GameData repair source override for development") accountSeed := fs.String("account-seed", "", "versioned local account seed") playerSeed := fs.String("player-seed", "", "versioned starter inventory and characters") readonlySeed := fs.String("readonly-seed", "", "versioned server schedules and optional feature defaults") mailSeed := fs.String("mail-seed", "", "versioned starter mailbox") - stateFile := fs.String("state", `..\data\state\state.db`, "local account SQLite database") + stateFile := fs.String("state", "", "account SQLite database override") deckSeed := fs.String("deck-seed", "", "versioned starter deck") worldSeed := fs.String("world-seed", "", "versioned starter world") gachaScheduleSeed := fs.String("gacha-schedule-seed", "", "versioned dynamic gacha schedule") - gameDir := fs.String("game-dir", "", "Brown Dust II client directory (required)") - identityPlugin := fs.String("identity-plugin", "", "optional BD2LocalIdentity.dll override for development") devToolsConfig := fs.String("dev-tools-config", "", "optional local development-tool settings JSON") if err := fs.Parse(args); err != nil { return err @@ -102,6 +111,49 @@ func serve(args []string) (serveErr error) { } } versionconfig.Use(versions) + if *authConfigPath == "" { + *authConfigPath, err = authconfig.BesideExecutable() + if err != nil { + return err + } + } + authentication, err := authconfig.Load(*authConfigPath) + if err != nil { + return err + } + authRuntime, err := authentication.ResolveEnvironment() + if err != nil { + return err + } + defer clear(authRuntime.MasterKey) + if *resourceConfigPath == "" { + *resourceConfigPath, err = resourcepolicy.BesideExecutable() + if err != nil { + return err + } + } + resources, err := resourcepolicy.Load(*resourceConfigPath) + if err != nil { + return err + } + if *dataDir == "" { + executable, executableErr := os.Executable() + if executableErr != nil { + return fmt.Errorf("resolve server data directory: %w", executableErr) + } + *dataDir = filepath.Join(filepath.Dir(executable), "data") + } + *dataDir, err = filepath.Abs(filepath.Clean(*dataDir)) + if err != nil { + return fmt.Errorf("resolve server data directory: %w", err) + } + gameData := filepath.Join(*dataDir, "resources", "GameData") + if *stateFile == "" { + *stateFile = filepath.Join(*dataDir, "state", "state.db") + } + if err := os.MkdirAll(filepath.Dir(filepath.Clean(*stateFile)), 0o755); err != nil { + return fmt.Errorf("create server state directory: %w", err) + } seedRoot := versions.Resolve(versions.SeedDirectory) for target, name := range map[*string]string{ accountSeed: "login_user.json", playerSeed: "starter_player.json", readonlySeed: "readonly.json", @@ -111,38 +163,24 @@ func serve(args []string) (serveErr error) { *target = filepath.Join(seedRoot, name) } } - if *cdn == "" || *gameData == "" || *gameDir == "" { - return errors.New("serve requires --game-dir, --cdn, and --game-data") + clientOrigin := "http://" + *listen + if authentication.Mode == "oauth" { + clientOrigin = strings.TrimSuffix(authentication.PublicURL, "/") } - packagedPlugin, err := clientplugin.ResolvePackaged(*identityPlugin) - if err != nil { - return err - } - pluginResult, err := clientplugin.Install(*gameDir, packagedPlugin) - if err != nil { - return err - } - if pluginResult.Changed { - slog.Info("installed local identity plugin", "path", pluginResult.Destination) - } else { - slog.Info("local identity plugin is current", "path", pluginResult.Destination) - } - base := "http://" + *listen + "/game/" + base := clientOrigin + "/game/" + publicResources := resources.Public(versions.BundleVersion, *gameDataVersion) cfg := bootstrap.Config{ BaseURL: base, - CDNURL: "http://" + *listen + "/assets/ServerData", + CDNURL: publicResources.ServerDataURL, Version: versions.ClientVersion, BundleVer: versions.BundleVersion, - GameDataURL: "http://" + *listen + "/assets/GameData", + GameDataURL: publicResources.GameDataURL, GameDataVer: *gameDataVersion, } if err := cfg.Validate(); err != nil { return err } - if info, err := os.Stat(*cdn); err != nil || !info.IsDir() { - return fmt.Errorf("CDN directory is unavailable: %q", *cdn) - } - verifiedGameData, downloaded, err := gamedata.Ensure(context.Background(), nil, filepath.Clean(*gameData), *gameDataVersion, *gameDataOrigin) + verifiedGameData, downloaded, err := gamedata.Ensure(context.Background(), nil, gameData, *gameDataVersion, *gameDataOrigin) if err != nil { return fmt.Errorf("refuse to advertise unavailable or unverified GameData: %w", err) } @@ -157,8 +195,8 @@ func serve(args []string) (serveErr error) { if err != nil { return fmt.Errorf("load starter player: %w", err) } - if login.Version != versions.ProtocolVersion || starter.Version != versions.ProtocolVersion { - return fmt.Errorf("protocol version %s requires matching account and player seeds (got %s and %s)", versions.ProtocolVersion, login.Version, starter.Version) + if login.Version != versions.ClientVersion || starter.Version != versions.ClientVersion { + return fmt.Errorf("client version %s requires matching account and player seeds (got %s and %s)", versions.ClientVersion, login.Version, starter.Version) } gachaSchedule, err := gacha.LoadScheduleSeed(filepath.Clean(*gachaScheduleSeed), versions.ClientVersion) if err != nil { @@ -171,7 +209,7 @@ func serve(args []string) (serveErr error) { for _, window := range gachaSchedule.StepUps { stepUpGroupIDs = append(stepUpGroupIDs, window.GroupID) } - regularGacha, equipmentGacha, err := gamedata.LoadActiveGachaForSchedules(filepath.Clean(*gameData), *gameDataVersion, scheduleGroupIDs, stepUpGroupIDs) + regularGacha, equipmentGacha, err := gamedata.LoadActiveGachaForSchedules(gameData, *gameDataVersion, scheduleGroupIDs, stepUpGroupIDs) if err != nil { return fmt.Errorf("load active gacha GameData: %w", err) } @@ -180,6 +218,18 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("open account state database: %w", err) } defer stateRepository.Close() + var authService *auth.Service + if authentication.Mode == "oauth" { + authStore, err := auth.Open(filepath.Join(filepath.Dir(filepath.Clean(*stateFile)), "auth.db"), authRuntime.MasterKey) + if err != nil { + return fmt.Errorf("open authentication database: %w", err) + } + defer authStore.Close() + authService, err = auth.New(authRuntime, authStore) + if err != nil { + return err + } + } accountDomains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"} if !stateRepository.IsNew() { if err := stateRepository.RequireDomains(accountDomains...); err != nil { @@ -226,7 +276,7 @@ func serve(args []string) (serveErr error) { if err != nil { return fmt.Errorf("load owned inventory: %w", err) } - randomBoxes, err := gamedata.LoadRandomBoxDesign(filepath.Clean(*gameData), *gameDataVersion) + randomBoxes, err := gamedata.LoadRandomBoxDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load deterministic random-box GameData: %w", err) } @@ -259,7 +309,7 @@ func serve(args []string) (serveErr error) { if err := login.AttachCurrencies(wallet); err != nil { return fmt.Errorf("attach wallet to login: %w", err) } - slotDesign, err := gamedata.LoadInventorySlotDesign(filepath.Clean(*gameData), *gameDataVersion) + slotDesign, err := gamedata.LoadInventorySlotDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load inventory slot GameData: %w", err) } @@ -286,7 +336,7 @@ func serve(args []string) (serveErr error) { if err := mailService.AttachSeedPath(filepath.Clean(*mailSeed)); err != nil { return fmt.Errorf("watch mail seed: %w", err) } - missionDesign, err := gamedata.LoadMissionDesign(filepath.Clean(*gameData), *gameDataVersion) + missionDesign, err := gamedata.LoadMissionDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load mission GameData: %w", err) } @@ -310,39 +360,39 @@ func serve(args []string) (serveErr error) { if err != nil { return fmt.Errorf("load owned equipment: %w", err) } - equipmentSlots, err := gamedata.LoadEquipmentSlots(filepath.Clean(*gameData), *gameDataVersion) + equipmentSlots, err := gamedata.LoadEquipmentSlots(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load equipment slot GameData: %w", err) } if err := ownedEquipment.AttachSlots(equipmentSlots); err != nil { return fmt.Errorf("attach equipment slot GameData: %w", err) } - equipmentUpgrade, err := gamedata.LoadEquipmentUpgradeDesign(filepath.Clean(*gameData), *gameDataVersion) + equipmentUpgrade, err := gamedata.LoadEquipmentUpgradeDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load equipment upgrade GameData: %w", err) } if err := ownedEquipment.AttachUpgrade(equipmentUpgrade, wallet, ownedItems); err != nil { return fmt.Errorf("attach equipment upgrade GameData: %w", err) } - equipmentCraft, err := gamedata.LoadEquipmentCraftDesign(filepath.Clean(*gameData), *gameDataVersion) + equipmentCraft, err := gamedata.LoadEquipmentCraftDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load equipment crafting GameData: %w", err) } - talentGrowth, err := gamedata.LoadTalentGrowthDesign(filepath.Clean(*gameData), *gameDataVersion) + talentGrowth, err := gamedata.LoadTalentGrowthDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load talent growth GameData: %w", err) } if err := ownedEquipment.AttachCraft(equipmentCraft); err != nil { return fmt.Errorf("attach equipment crafting GameData: %w", err) } - equipmentSmelting, err := gamedata.LoadEquipmentSmeltingDesign(filepath.Clean(*gameData), *gameDataVersion) + equipmentSmelting, err := gamedata.LoadEquipmentSmeltingDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load equipment smelting GameData: %w", err) } if err := ownedEquipment.AttachSmelting(equipmentSmelting, wallet, ownedItems); err != nil { return fmt.Errorf("attach equipment smelting GameData: %w", err) } - equipmentOptionReroll, err := gamedata.LoadEquipmentOptionRerollDesign(filepath.Clean(*gameData), *gameDataVersion) + equipmentOptionReroll, err := gamedata.LoadEquipmentOptionRerollDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load equipment option reroll GameData: %w", err) } @@ -353,7 +403,7 @@ func serve(args []string) (serveErr error) { if err != nil { return fmt.Errorf("load owned collection: %w", err) } - infiniteGacha, err := gamedata.LoadInfiniteGacha(filepath.Clean(*gameData), *gameDataVersion) + infiniteGacha, err := gamedata.LoadInfiniteGacha(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load infinite gacha GameData: %w", err) } @@ -382,7 +432,7 @@ func serve(args []string) (serveErr error) { gachaService.AttachPreviewMission(func() error { return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 111}) }) - worldService, err := world.Load(filepath.Clean(*worldSeed), filepath.Clean(*gameData), *gameDataVersion, + worldService, err := world.Load(filepath.Clean(*worldSeed), gameData, *gameDataVersion, stateRepository, progressState, starter, ownedEquipment, ownedItems, wallet) if err != nil { return fmt.Errorf("load world state: %w", err) @@ -407,12 +457,12 @@ func serve(args []string) (serveErr error) { if err := deckStateStore.AttachPresetRuntime(wallet, worldService.CharacterService(), ownedEquipment, collection); err != nil { return fmt.Errorf("attach ordinary preset runtime: %w", err) } - pictorialDesign, err := gamedata.LoadPictorialDesign(filepath.Clean(*gameData), *gameDataVersion) + pictorialDesign, err := gamedata.LoadPictorialDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load pictorial GameData: %w", err) } pictorialService := &pictorial.Service{Design: pictorialDesign, Owned: worldService} - charAwakeDesign, err := gamedata.LoadCharAwakeDesign(filepath.Clean(*gameData), *gameDataVersion) + charAwakeDesign, err := gamedata.LoadCharAwakeDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load character awakening GameData: %w", err) } @@ -430,7 +480,7 @@ func serve(args []string) (serveErr error) { if err := worldService.CharacterService().AttachTalentGrowth(talentGrowth); err != nil { return fmt.Errorf("attach character talent growth: %w", err) } - costumePotentialDesign, err := gamedata.LoadCostumePotentialDesign(filepath.Clean(*gameData), *gameDataVersion) + costumePotentialDesign, err := gamedata.LoadCostumePotentialDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load costume potential GameData: %w", err) } @@ -438,7 +488,7 @@ func serve(args []string) (serveErr error) { if err != nil { return err } - battleService := battle.NewService(filepath.Clean(*gameData), *gameDataVersion, ownedItems, worldService.CurrentPackID) + battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID) battleService.AttachTutorialWin(func() error { return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113}) }) @@ -469,6 +519,11 @@ func serve(args []string) (serveErr error) { if err != nil { return err } + if authService != nil { + if err := game.AttachLoginAuthenticator(authService); err != nil { + return err + } + } if stateRepository.IsNew() { if err := ensureAccountStateInitialized( progressState, deckStateStore, ownedItems, ownedEquipment, @@ -495,7 +550,14 @@ func serve(args []string) (serveErr error) { return err } dispatcher := transport.Bootstrap{Config: cfg} - handler := transport.HTTP{Dispatcher: dispatcher, Raw: game, CDNDir: filepath.Clean(*cdn), GameDataDir: filepath.Clean(*gameData)}.Handler() + var authHandler http.Handler + if authService != nil { + authHandler = authService.Handler() + } + handler := transport.HTTP{ + Dispatcher: dispatcher, Raw: game, Authentication: authentication, + AuthenticationHandler: authHandler, ResourcePolicy: publicResources, + }.Handler() server := &http.Server{ Addr: *listen, Handler: handler, @@ -504,7 +566,7 @@ func serve(args []string) (serveErr error) { WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second, } - slog.Info("BD2 local server listening", "address", *listen, "client", cfg.Version, "bundle", cfg.BundleVer, "cdn", *cdn, "gameData", verifiedGameData.ArchivePath, "gameDataEntries", verifiedGameData.EntryCount, "accountSeed", *accountSeed) + slog.Info("BD2 server listening", "address", *listen, "client", cfg.Version, "bundle", cfg.BundleVer, "resourceMode", publicResources.Mode, "gameData", verifiedGameData.ArchivePath, "gameDataEntries", verifiedGameData.EntryCount, "accountSeed", *accountSeed) return server.ListenAndServe() } @@ -524,73 +586,49 @@ func ensureAccountStateInitialized(stores ...accountStateInitializer) error { return nil } -func patchClient(args []string) error { - fs := flag.NewFlagSet("patch-client", flag.ContinueOnError) - gameDir := fs.String("game-dir", "", "BrownDust II game directory (required)") - serverURL := fs.String("url", "http://127.0.0.1:8080/game/", "exactly 27-byte replacement LIVE_URL") - verify := fs.Bool("verify", false, "inspect the embedded LIVE_URL without writing") - if err := fs.Parse(args); err != nil { +func resourcesCommand(args []string) error { + if len(args) == 0 || args[0] != "fetch" { + return errors.New("resources requires the fetch subcommand") + } + fs := flag.NewFlagSet("resources fetch", flag.ContinueOnError) + versionConfigPath := fs.String("version-config", "", "repository versions.json override") + output := fs.String("output", "", "resource mirror output directory (required)") + platform := fs.String("platform", "StandaloneWindows64", "official ServerData platform") + if err := fs.Parse(args[1:]); err != nil { return err } - if *gameDir == "" { - return errors.New("patch-client requires --game-dir") + if *output == "" { + return errors.New("resources fetch requires --output") } - if *verify { - result, err := introdb.VerifyClient(*gameDir) - if err != nil { - return err - } - fmt.Printf("resources.assets: %s\nTextAsset pathID: %d\nLIVE_URL: %s\n", result.AssetsPath, result.ObjectPath, result.URL) - return nil + var versions versionconfig.Config + var err error + if *versionConfigPath == "" { + versions, err = versionconfig.Find() + } else { + versions, err = versionconfig.Load(*versionConfigPath) } - result, err := introdb.PatchClient(*gameDir, *serverURL) if err != nil { return err } - disabled, err := disableLegacyPlugin(*gameDir) + manifest, err := resourcefetch.Fetch(context.Background(), resourcefetch.Options{ + OutputRoot: *output, Platform: *platform, BundleVersion: versions.BundleVersion, + GameDataVersion: versions.GameDataVersion, + Progress: func(message string) { slog.Info(message) }, + }) if err != nil { return err } - verified, err := introdb.VerifyClient(*gameDir) - if err != nil { - return fmt.Errorf("post-patch verification: %w", err) - } - fmt.Printf("patched: %s\nbackup: %s\nLIVE_URL: %s\n", result.AssetsPath, result.BackupPath, verified.URL) - if disabled != "" { - fmt.Printf("legacy plugin disabled: %s\n", disabled) - } + slog.Info("official resource mirror complete", "output", *output, "bundles", manifest.ServerData.Bundles, "bytes", manifest.ServerData.Bytes) return nil } -func disableLegacyPlugin(gameDir string) (string, error) { - source := filepath.Join(gameDir, "BepInEx", "plugins", "PluginLocalRes.dll") - destination := filepath.Join(gameDir, "BepInEx", "disabled", "PluginLocalRes.dll") - if _, err := os.Stat(source); errors.Is(err, os.ErrNotExist) { - return "", nil - } else if err != nil { - return "", fmt.Errorf("inspect legacy local-resource plugin: %w", err) - } - if _, err := os.Stat(destination); err == nil { - return "", fmt.Errorf("legacy plugin exists at both active and disabled paths") - } else if !errors.Is(err, os.ErrNotExist) { - return "", err - } - if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { - return "", err - } - if err := os.Rename(source, destination); err != nil { - return "", fmt.Errorf("disable legacy local-resource plugin: %w", err) - } - return destination, nil -} - func usage() { - fmt.Fprintln(os.Stderr, `bd2server - BrownDust II local development server + fmt.Fprintln(os.Stderr, `bd2server - BrownDust II server Usage: - bd2server serve --game-dir DIR --cdn DIR --game-data DIR [--version-config FILE] [options] - bd2server patch-client --game-dir DIR [options] - bd2server state check [options] + bd2server serve [--data-dir DIR] [--version-config FILE] [options] + bd2server resources fetch --output DIR [--version-config FILE] + bd2server state check [options] -The server binds to loopback by default and is intended for local research.`) +The server binds to loopback by default.`+developmentUsage()) } diff --git a/go/cmd/bd2server/state.go b/go/cmd/bd2server/state.go index 3511c2c..7f43a5a 100644 --- a/go/cmd/bd2server/state.go +++ b/go/cmd/bd2server/state.go @@ -6,7 +6,7 @@ import ( "fmt" "path/filepath" - "bd2server/internal/accountstate" + "bd2server/internal/server/accountstate" ) func stateCommand(args []string) error { diff --git a/go/cmd/bd2server/state_validation.go b/go/cmd/bd2server/state_validation.go index ab2f518..bae01a2 100644 --- a/go/cmd/bd2server/state_validation.go +++ b/go/cmd/bd2server/state_validation.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "bd2server/internal/accountstate" + "bd2server/internal/server/accountstate" ) func stateProblemsError(prefix string, problems []accountstate.Problem) error { diff --git a/go/internal/client/app/app.go b/go/internal/client/app/app.go new file mode 100644 index 0000000..e544ff3 --- /dev/null +++ b/go/internal/client/app/app.go @@ -0,0 +1,519 @@ +// Package app serves bd2client's embedded, loopback-only setup interface. +package app + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "embed" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "sync" + "time" + + clientconfig "bd2server/internal/client/config" + clientlayout "bd2server/internal/client/layout" + clientsetup "bd2server/internal/client/setup" +) + +//go:embed web/index.html +var webFS embed.FS + +var errGameAlreadyRunning = errors.New("Brown Dust II is already running") + +type Options struct { + Listen string + NoBrowser bool + InitialGameDir string + Logger *slog.Logger + LogPath string + Versions clientconfig.ReleaseVersions + LocalIdentityPlugin string + LoginUIPlugin string +} + +type request struct { + GameDirectory string `json:"game_directory"` + ServerOrigin string `json:"server_origin"` + CDNMode clientconfig.CDNMode `json:"cdn_mode"` + LocalResourceDirectory string `json:"local_resource_directory"` + UILanguage string `json:"ui_language"` +} + +type response struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Data any `json:"data,omitempty"` +} + +type handler struct { + token string + origin string + initialGameDir string + browse func(string) (string, error) + browseResources func(string) (string, error) + shutdown func() + quitOnce sync.Once + logger *slog.Logger + logPath string + versions clientconfig.ReleaseVersions + initialSettings clientconfig.Settings + autoOpen bool + localIdentityPlugin string + loginUIPlugin string +} + +func Run(options Options) error { + logger := options.Logger + if logger == nil { + logger = slog.Default() + } + listen := options.Listen + if listen == "" { + listen = "127.0.0.1:0" + } + listener, err := net.Listen("tcp", listen) + if err != nil { + logger.Error("could not start local interface", "error", err) + return fmt.Errorf("start bd2client interface: %w", err) + } + address := listener.Addr().(*net.TCPAddr) + if !address.IP.IsLoopback() { + _ = listener.Close() + logger.Error("refused non-loopback interface", "address", listener.Addr().String()) + return errors.New("bd2client interface must listen on a loopback address") + } + token, err := newToken() + if err != nil { + _ = listener.Close() + logger.Error("could not create local interface session", "error", err) + return err + } + origin := "http://" + listener.Addr().String() + initialSettings := clientconfig.Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial} + autoOpen := false + if options.InitialGameDir != "" { + if _, inspectErr := clientsetup.Inspect(options.InitialGameDir, options.Versions); inspectErr != nil { + logger.Warn("saved game directory is no longer valid", "error", inspectErr) + } else if loaded, loadErr := clientconfig.Load(options.InitialGameDir); loadErr != nil { + logger.Warn("saved client connection settings are unavailable", "error", loadErr) + } else { + initialSettings = loaded + autoOpen = true + } + } + server := &http.Server{ + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + done := make(chan struct{}) + h := &handler{ + token: token, + origin: origin, + initialGameDir: options.InitialGameDir, + browse: browseForGameDirectory, + browseResources: browseForResourceDirectory, + logger: logger, + logPath: options.LogPath, + versions: options.Versions, + initialSettings: initialSettings, + autoOpen: autoOpen, + localIdentityPlugin: options.LocalIdentityPlugin, + loginUIPlugin: options.LoginUIPlugin, + shutdown: func() { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + }() + }, + } + server.Handler = h.routes() + logger.Info("local interface listening", "address", listener.Addr().String()) + go func() { + err := server.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("local interface stopped unexpectedly", "error", err) + } + close(done) + }() + pageURL := origin + "/?session=" + token + fmt.Fprintf(os.Stdout, "BD2 Client Studio: %s\n", pageURL) + if !options.NoBrowser { + if err := openBrowser(pageURL); err != nil { + logger.Warn("could not open client window automatically", "error", err) + } else { + logger.Info("client window opened") + } + } else { + logger.Info("automatic client window disabled") + } + <-done + logger.Info("local interface stopped") + return nil +} + +func (h *handler) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /", h.index) + mux.HandleFunc("POST /api/browse", h.observe("browse game directory", h.authorize(h.browseDirectory))) + mux.HandleFunc("POST /api/browse-resources", h.observe("browse resource directory", h.authorize(h.browseResourceDirectory))) + mux.HandleFunc("POST /api/inspect", h.observe("inspect game directory", h.authorize(h.inspect))) + mux.HandleFunc("POST /api/resources", h.observe("check resource policy", h.authorize(h.resources))) + mux.HandleFunc("POST /api/save", h.observe("save settings", h.authorize(h.save))) + mux.HandleFunc("POST /api/patch", h.observe("patch client", h.authorize(h.patch))) + mux.HandleFunc("POST /api/install", h.observe("install plugins", h.authorize(h.install))) + mux.HandleFunc("POST /api/launch", h.observe("launch game", h.authorize(h.launch))) + mux.HandleFunc("POST /api/quit", h.observe("quit", h.authorize(h.quit))) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Frame-Options", "DENY") + mux.ServeHTTP(w, r) + }) +} + +func (h *handler) index(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" || r.URL.Query().Get("session") != h.token { + http.NotFound(w, r) + return + } + data, err := webFS.ReadFile("web/index.html") + if err != nil { + http.Error(w, "embedded interface unavailable", http.StatusInternalServerError) + return + } + tmpl, err := template.New("index").Parse(string(data)) + if err != nil { + http.Error(w, "embedded interface invalid", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'self'; font-src 'self'") + _ = tmpl.Execute(w, map[string]string{ + "Token": h.token, "GameDirectory": h.initialGameDir, "LogPath": h.logPath, "Platform": runtime.GOOS, + "ServerOrigin": h.initialSettings.ServerOrigin, "CDNMode": string(h.initialSettings.CDNMode), + "LocalResourceDirectory": h.initialSettings.LocalResourceDirectory, + "AutoOpen": fmt.Sprintf("%t", h.autoOpen), + }) +} + +func (h *handler) authorize(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + provided := r.Header.Get("X-BD2-Session") + if len(provided) != len(h.token) || subtle.ConstantTimeCompare([]byte(provided), []byte(h.token)) != 1 { + h.log().Warn("API request rejected", "operation", r.URL.Path, "reason", "invalid session") + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if origin := r.Header.Get("Origin"); origin != "" && origin != h.origin { + h.log().Warn("API request rejected", "operation", r.URL.Path, "reason", "foreign origin") + http.Error(w, "forbidden origin", http.StatusForbidden) + return + } + next(w, r) + } +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (h *handler) observe(operation string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tracked := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next(tracked, r) + if tracked.status >= http.StatusBadRequest { + h.log().Error("client API operation failed", "operation", operation, "status", tracked.status) + return + } + h.log().Info("client API operation completed", "operation", operation, "status", tracked.status) + } +} + +func (h *handler) log() *slog.Logger { + if h.logger != nil { + return h.logger + } + return slog.Default() +} + +func (h *handler) browseDirectory(w http.ResponseWriter, r *http.Request) { + input, ok := h.decode(w, r) + if !ok { + return + } + h.log().Info("directory selection opened", "kind", "game") + dir, err := h.browse(input.UILanguage) + if err != nil { + h.writeError(w, err) + return + } + if dir == "" { + h.log().Info("directory selection cancelled", "kind", "game") + h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Selection cancelled"}) + return + } + status, err := clientsetup.Inspect(dir, h.versions) + if err != nil { + h.writeError(w, err) + return + } + h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Game client found", Data: status}) +} + +func (h *handler) browseResourceDirectory(w http.ResponseWriter, r *http.Request) { + input, ok := h.decode(w, r) + if !ok { + return + } + h.log().Info("directory selection opened", "kind", "resources") + dir, err := h.browseResources(input.UILanguage) + if err != nil { + h.writeError(w, err) + return + } + if dir == "" { + h.log().Info("directory selection cancelled", "kind", "resources") + h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Selection cancelled"}) + return + } + policy, err := clientsetup.FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ + ServerOrigin: "http://127.0.0.1", + CDNMode: clientconfig.CDNLocal, + LocalResourceDirectory: dir, + }, h.versions) + if err != nil { + h.writeError(w, err) + return + } + h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Local resource directory found", Data: policy}) +} + +func (h *handler) inspect(w http.ResponseWriter, r *http.Request) { + input, ok := h.decode(w, r) + if !ok { + return + } + status, err := clientsetup.Inspect(input.GameDirectory, h.versions) + if err != nil { + h.writeError(w, err) + return + } + h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Game directory is valid", Data: status}) +} + +func (h *handler) resources(w http.ResponseWriter, r *http.Request) { + input, ok := h.decode(w, r) + if !ok { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) + defer cancel() + policy, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), h.versions) + if err != nil { + h.writeError(w, err) + return + } + message := "The client will use the release-locked official CDN" + if policy.Mode == clientconfig.CDNLocal { + message = "Local resources verified" + } else if policy.Mode == clientconfig.CDNServer { + message = "Server resource policy verified" + } + h.writeJSON(w, http.StatusOK, response{OK: true, Message: message, Data: policy}) +} + +func (h *handler) save(w http.ResponseWriter, r *http.Request) { + input, ok := h.decode(w, r) + if !ok { + return + } + settings, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), h.versions) + if err != nil { + h.writeError(w, err) + return + } + if err := clientconfig.SavePreferences(input.GameDirectory); err != nil { + h.writeError(w, err) + return + } + h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Connection settings saved", Data: settings}) +} + +func (h *handler) patch(w http.ResponseWriter, r *http.Request) { + input, ok := h.decode(w, r) + if !ok { + return + } + result, err := clientsetup.Patch(input.GameDirectory, input.settings(), h.versions) + if err != nil { + h.writeError(w, err) + return + } + if err := clientconfig.SavePreferences(input.GameDirectory); err != nil { + h.writeError(w, err) + return + } + message := "Client entry point patched; the original backup was retained" + if !result.Changed { + message = "Client patch is already complete; no asset file was rewritten" + } + h.writeJSON(w, http.StatusOK, response{OK: true, Message: message, Data: result}) +} + +func (h *handler) install(w http.ResponseWriter, r *http.Request) { + input, ok := h.decode(w, r) + if !ok { + return + } + result, err := clientsetup.InstallPlugins( + input.GameDirectory, + input.settings(), + h.versions, + h.localIdentityPlugin, + h.loginUIPlugin, + ) + if err != nil { + h.writeError(w, err) + return + } + if err := clientconfig.SavePreferences(input.GameDirectory); err != nil { + h.writeError(w, err) + return + } + message := "BD2 client plugins installed or updated" + if !result.LocalIdentity.Changed && !result.LoginUI.Changed { + message = "BD2 client plugins are already up to date; no DLL was rewritten" + } + h.writeJSON(w, http.StatusOK, response{OK: true, Message: message, Data: result}) +} + +func (h *handler) launch(w http.ResponseWriter, r *http.Request) { + input, ok := h.decode(w, r) + if !ok { + return + } + status, err := clientsetup.Inspect(input.GameDirectory, h.versions) + if err != nil { + h.writeError(w, err) + return + } + if status.PatchedURL != clientsetup.PatchPlaceholder { + h.writeError(w, errors.New("apply the client patch before launching the game")) + return + } + if !status.BepInEx { + h.writeError(w, errors.New("install BepInEx before launching the game")) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) + defer cancel() + if _, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), h.versions); err != nil { + h.writeError(w, err) + return + } + if _, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), h.versions); err != nil { + h.writeError(w, err) + return + } + if err := clientconfig.SavePreferences(input.GameDirectory); err != nil { + h.writeError(w, err) + return + } + installation, err := clientlayout.Resolve(status.GameDirectory) + if err != nil { + h.writeError(w, err) + return + } + if !installation.SupportedOnHost() { + h.writeError(w, fmt.Errorf("cannot launch a %s game client from this operating system", installation.Kind)) + return + } + for _, pluginName := range []string{"BD2LocalIdentity.dll", "BD2LoginUI.dll"} { + info, statErr := os.Stat(filepath.Join(installation.Plugins, pluginName)) + if statErr != nil || !info.Mode().IsRegular() { + h.writeError(w, fmt.Errorf("install or update the client plugins before launching; %s is missing", pluginName)) + return + } + } + if err := launchGame(installation.LaunchTarget()); err != nil { + if errors.Is(err, errGameAlreadyRunning) { + h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Brown Dust II is already running"}) + return + } + h.writeError(w, fmt.Errorf("launch Brown Dust II: %w", err)) + return + } + h.log().Info("game launch requested", "platform", installation.Kind, "client_version", status.ClientVersion) + h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Brown Dust II started"}) +} + +func (h *handler) quit(w http.ResponseWriter, _ *http.Request) { + h.log().Info("client exit requested") + h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Client tool exited"}) + h.quitOnce.Do(h.shutdown) +} + +func (h *handler) decode(w http.ResponseWriter, r *http.Request) (request, bool) { + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + var input request + if err := decoder.Decode(&input); err != nil { + h.writeError(w, fmt.Errorf("invalid request: %w", err)) + return request{}, false + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + h.writeError(w, errors.New("request must contain exactly one JSON object")) + return request{}, false + } + return input, true +} + +func (r request) settings() clientconfig.Settings { + return clientconfig.Settings{ + ServerOrigin: r.ServerOrigin, + CDNMode: r.CDNMode, + LocalResourceDirectory: r.LocalResourceDirectory, + } +} + +func (h *handler) writeError(w http.ResponseWriter, err error) { + h.log().Error("client operation error", "error", err) + h.writeJSON(w, http.StatusBadRequest, response{OK: false, Message: err.Error()}) +} + +func (h *handler) writeJSON(w http.ResponseWriter, status int, value response) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func newToken() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", fmt.Errorf("generate UI session: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} diff --git a/go/internal/client/app/app_test.go b/go/internal/client/app/app_test.go new file mode 100644 index 0000000..2ae9b0b --- /dev/null +++ b/go/internal/client/app/app_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + clientconfig "bd2server/internal/client/config" +) + +func TestIndexRequiresSessionAndServesEmbeddedStudio(t *testing.T) { + h := &handler{token: "test-session", origin: "http://127.0.0.1"} + server := httptest.NewServer(h.routes()) + defer server.Close() + + response, err := http.Get(server.URL + "/") + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusNotFound { + t.Fatalf("without session status=%d", response.StatusCode) + } + + response, err = http.Get(server.URL + "/?session=test-session") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + buffer, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + page := string(buffer) + for _, marker := range []string{ + "BD2 Client Studio", "test-session", `name="bd2-platform"`, + `id="directoryScene"`, `id="serverScene"`, `id="deskScene"`, + `id="gameDir"`, `id="origin"`, `id="patch"`, `id="install"`, `id="launch"`, + `value="official"`, `value="local"`, `value="server"`, + `id="localResourceDir"`, `id="browseResources"`, + `Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Macau`, `Asia/Taipei`, + `const zhCN=CHINA_TIME_ZONES.has(detectedTimeZone)`, + "opening-curtain", "is-entering", "@keyframes reveal", "prefers-reduced-motion", + } { + if !strings.Contains(page, marker) { + t.Errorf("page lacks %q", marker) + } + } + if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("Cache-Control") != "no-store" { + t.Fatalf("security headers=%v", response.Header) + } +} + +func TestAPIRejectsMalformedAndTrailingJSON(t *testing.T) { + h := &handler{token: "test-session", origin: "http://local.invalid", versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}} + for name, body := range map[string]string{ + "malformed": `{`, + "trailing": `{}` + `{}`, + "unknown": `{"unexpected":true}`, + } { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(body)) + request.Header.Set("X-BD2-Session", "test-session") + request.Header.Set("Origin", "http://local.invalid") + response := httptest.NewRecorder() + h.routes().ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + }) + } +} + +func TestAPIRejectsMissingTokenAndForeignOrigin(t *testing.T) { + h := &handler{token: "test-session", origin: "http://local.invalid", browse: func(string) (string, error) { return "", nil }} + for name, values := range map[string][2]string{ + "missing token": {"", "http://local.invalid"}, + "foreign origin": {"test-session", "https://attacker.invalid"}, + } { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/api/browse", strings.NewReader("{}")) + request.Header.Set("X-BD2-Session", values[0]) + request.Header.Set("Origin", values[1]) + response := httptest.NewRecorder() + h.routes().ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + }) + } +} + +func TestInspectAPI(t *testing.T) { + dir := t.TempDir() + for path, data := range map[string][]byte{ + filepath.Join(dir, "BrownDust II.exe"): []byte("exe"), + filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("not a real Unity file"), + filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + h := &handler{token: "test-session", origin: "http://local.invalid", versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}} + body, _ := json.Marshal(request{GameDirectory: dir}) + req := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(string(body))) + req.Header.Set("X-BD2-Session", "test-session") + req.Header.Set("Origin", "http://local.invalid") + recorder := httptest.NewRecorder() + h.routes().ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var result response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !result.OK { + t.Fatalf("response=%+v", result) + } +} diff --git a/go/internal/client/app/directory_windows.go b/go/internal/client/app/directory_windows.go new file mode 100644 index 0000000..e691f90 --- /dev/null +++ b/go/internal/client/app/directory_windows.go @@ -0,0 +1,147 @@ +//go:build windows + +package app + +import ( + "fmt" + "runtime" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + coinitApartmentThreaded = 0x2 + clsctxInprocServer = 0x1 + + fosNoChangeDir = 0x00000008 + fosPickFolders = 0x00000020 + fosForceFileSystem = 0x00000040 + fosPathMustExist = 0x00000800 + fosDontAddToRecent = 0x02000000 + + sigdnFileSystemPath = 0x80058000 + errorCancelled = 0x800704c7 +) + +var ( + ole32DLL = windows.NewLazySystemDLL("ole32.dll") + user32DLL = windows.NewLazySystemDLL("user32.dll") + coInitializeEx = ole32DLL.NewProc("CoInitializeEx") + coUninitialize = ole32DLL.NewProc("CoUninitialize") + coCreateInstance = ole32DLL.NewProc("CoCreateInstance") + coTaskMemFree = ole32DLL.NewProc("CoTaskMemFree") + getForegroundWindow = user32DLL.NewProc("GetForegroundWindow") + clsidFileOpenDialog = windows.GUID{Data1: 0xdc1c5a9c, Data2: 0xe88a, Data3: 0x4dde, Data4: [8]byte{0xa5, 0xa1, 0x60, 0xf8, 0x2a, 0x20, 0xae, 0xf7}} + iidIFileOpenDialog = windows.GUID{Data1: 0xd57c7288, Data2: 0xd4ad, Data3: 0x4768, Data4: [8]byte{0xbe, 0x02, 0x9d, 0x96, 0x95, 0x32, 0xd9, 0x60}} +) + +// comObject is sufficient for IFileOpenDialog and IShellItem because COM +// interfaces begin with a pointer to a vtable. The methods used below are +// selected by their documented vtable positions. +type comObject struct { + vtable *[29]uintptr +} + +func browseForDirectory(titleText string) (string, error) { + // COM apartment state belongs to an OS thread. Keep this handler on one + // thread from initialization until every interface has been released. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + result, _, _ := coInitializeEx.Call(0, coinitApartmentThreaded) + if hresultFailed(result) { + return "", hresultError("initialize Windows directory picker", result) + } + defer coUninitialize.Call() + + var dialog *comObject + result, _, _ = coCreateInstance.Call( + uintptr(unsafe.Pointer(&clsidFileOpenDialog)), + 0, + clsctxInprocServer, + uintptr(unsafe.Pointer(&iidIFileOpenDialog)), + uintptr(unsafe.Pointer(&dialog)), + ) + if hresultFailed(result) { + return "", hresultError("create Windows directory picker", result) + } + if dialog == nil { + return "", fmt.Errorf("create Windows directory picker: the system returned no dialog") + } + defer comRelease(dialog) + + var options uint32 + result = comCall(dialog, 10, uintptr(unsafe.Pointer(&options))) // IFileDialog::GetOptions + if hresultFailed(result) { + return "", hresultError("read Windows directory picker options", result) + } + options |= fosNoChangeDir | fosPickFolders | fosForceFileSystem | fosPathMustExist | fosDontAddToRecent + result = comCall(dialog, 9, uintptr(options)) // IFileDialog::SetOptions + if hresultFailed(result) { + return "", hresultError("set Windows directory picker options", result) + } + + title, err := windows.UTF16PtrFromString(titleText) + if err != nil { + return "", fmt.Errorf("set Windows directory picker title: %w", err) + } + result = comCall(dialog, 17, uintptr(unsafe.Pointer(title))) // IFileDialog::SetTitle + runtime.KeepAlive(title) + if hresultFailed(result) { + return "", hresultError("set Windows directory picker title", result) + } + + owner, _, _ := getForegroundWindow.Call() + result = comCall(dialog, 3, owner) // IModalWindow::Show + if uint32(result) == errorCancelled { + return "", nil + } + if hresultFailed(result) { + return "", hresultError("show Windows directory picker", result) + } + + var item *comObject + result = comCall(dialog, 20, uintptr(unsafe.Pointer(&item))) // IFileDialog::GetResult + if hresultFailed(result) { + return "", hresultError("read selected directory", result) + } + if item == nil { + return "", fmt.Errorf("read selected directory: the system returned no directory") + } + defer comRelease(item) + + var path *uint16 + result = comCall(item, 5, sigdnFileSystemPath, uintptr(unsafe.Pointer(&path))) // IShellItem::GetDisplayName + if hresultFailed(result) { + return "", hresultError("read selected directory path", result) + } + if path == nil { + return "", fmt.Errorf("read selected directory path: the system returned an empty path") + } + defer coTaskMemFree.Call(uintptr(unsafe.Pointer(path))) + return windows.UTF16PtrToString(path), nil +} + +func comCall(object *comObject, method int, args ...uintptr) uintptr { + callArgs := make([]uintptr, 1, len(args)+1) + callArgs[0] = uintptr(unsafe.Pointer(object)) + callArgs = append(callArgs, args...) + result, _, _ := syscall.SyscallN(object.vtable[method], callArgs...) + return result +} + +func comRelease(object *comObject) { + if object != nil { + comCall(object, 2) // IUnknown::Release + } +} + +func hresultFailed(result uintptr) bool { + return int32(uint32(result)) < 0 +} + +func hresultError(action string, result uintptr) error { + return fmt.Errorf("%s: HRESULT 0x%08X", action, uint32(result)) +} diff --git a/go/internal/client/app/logging.go b/go/internal/client/app/logging.go new file mode 100644 index 0000000..cab767f --- /dev/null +++ b/go/internal/client/app/logging.go @@ -0,0 +1,128 @@ +package app + +import ( + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "runtime" + "sync" +) + +const ( + clientLogName = "bd2client.log" + clientLogBackupName = "bd2client.log.1" + clientLogMaxBytes = 2 << 20 +) + +// OpenPersistentLogger creates the GUI client's bounded, persistent log next +// to the executable. The active log is capped at 2 MiB and one previous log is +// retained, so a client left installed for a long time cannot grow without +// limit. +func OpenPersistentLogger(executablePath string) (*slog.Logger, io.Closer, string, error) { + if executablePath == "" { + var err error + executablePath, err = os.Executable() + if err != nil { + return nil, nil, "", fmt.Errorf("locate bd2client executable: %w", err) + } + } + absolute, err := filepath.Abs(executablePath) + if err != nil { + return nil, nil, "", fmt.Errorf("resolve bd2client executable path: %w", err) + } + logDirectory := filepath.Join(filepath.Dir(absolute), "logs") + if runtime.GOOS == "darwin" { + home, homeErr := os.UserHomeDir() + if homeErr != nil { + return nil, nil, "", fmt.Errorf("locate macOS user home for logs: %w", homeErr) + } + logDirectory = filepath.Join(home, "Library", "Logs", "BD2 Client Studio") + } + if err := os.MkdirAll(logDirectory, 0o700); err != nil { + return nil, nil, "", fmt.Errorf("create bd2client log directory: %w", err) + } + path := filepath.Join(logDirectory, clientLogName) + writer, err := openRollingLog(path, filepath.Join(logDirectory, clientLogBackupName), clientLogMaxBytes) + if err != nil { + return nil, nil, "", err + } + logger := slog.New(slog.NewTextHandler(writer, &slog.HandlerOptions{Level: slog.LevelInfo})) + return logger, writer, path, nil +} + +type rollingLog struct { + mu sync.Mutex + path string + backupPath string + maxBytes int64 + file *os.File + size int64 +} + +func openRollingLog(path, backupPath string, maxBytes int64) (*rollingLog, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return nil, fmt.Errorf("open bd2client log: %w", err) + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, fmt.Errorf("inspect bd2client log: %w", err) + } + return &rollingLog{ + path: path, + backupPath: backupPath, + maxBytes: maxBytes, + file: file, + size: info.Size(), + }, nil +} + +func (w *rollingLog) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return 0, os.ErrClosed + } + if w.size > 0 && w.size+int64(len(data)) > w.maxBytes { + if err := w.rotate(); err != nil { + return 0, err + } + } + written, err := w.file.Write(data) + w.size += int64(written) + return written, err +} + +func (w *rollingLog) rotate() error { + if err := w.file.Close(); err != nil { + return fmt.Errorf("close bd2client log for rotation: %w", err) + } + w.file = nil + if err := os.Remove(w.backupPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("replace bd2client log backup: %w", err) + } + if err := os.Rename(w.path, w.backupPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("rotate bd2client log: %w", err) + } + file, err := os.OpenFile(w.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("create rotated bd2client log: %w", err) + } + w.file = file + w.size = 0 + return nil +} + +func (w *rollingLog) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} diff --git a/go/internal/client/app/logging_test.go b/go/internal/client/app/logging_test.go new file mode 100644 index 0000000..4721289 --- /dev/null +++ b/go/internal/client/app/logging_test.go @@ -0,0 +1,107 @@ +package app + +import ( + "bytes" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "unicode" + "unicode/utf8" +) + +func TestOpenPersistentLoggerUsesExecutableLogDirectory(t *testing.T) { + root := t.TempDir() + executable := filepath.Join(root, "bd2client.exe") + logger, closer, logPath, err := OpenPersistentLogger(executable) + if err != nil { + t.Fatal(err) + } + logger.Info("test entry") + if err := closer.Close(); err != nil { + t.Fatal(err) + } + wantPath := filepath.Join(root, "logs", clientLogName) + if logPath != wantPath { + t.Fatalf("log path=%q want=%q", logPath, wantPath) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte("test entry")) { + t.Fatalf("log does not contain test entry: %s", data) + } +} + +func TestPersistentOperationalLogsUseEnglish(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, nil)) + h := &handler{token: "test-session", origin: "http://local.invalid", logger: logger} + body := `{"game_directory":"Z:\\missing","server_origin":"http://127.0.0.1:8080","cdn_mode":"official","local_resource_directory":"","ui_language":"zh-CN"}` + request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(body)) + request.Header.Set("X-BD2-Session", "test-session") + request.Header.Set("Origin", "http://local.invalid") + response := httptest.NewRecorder() + h.routes().ServeHTTP(response, request) + for len(output.Bytes()) > 0 { + r, size := utf8.DecodeRune(output.Bytes()) + if unicode.Is(unicode.Han, r) { + t.Fatalf("operational log contains Han character %q: %s", r, output.String()) + } + output.Next(size) + } +} + +func TestRollingLogRetainsOneBackup(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, clientLogName) + backup := filepath.Join(root, clientLogBackupName) + writer, err := openRollingLog(path, backup, 8) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte("first")); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte("second")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + active, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + previous, err := os.ReadFile(backup) + if err != nil { + t.Fatal(err) + } + if string(active) != "second" || string(previous) != "first" { + t.Fatalf("active=%q backup=%q", active, previous) + } +} + +func TestAuthorizationLogDoesNotIncludeSessionValues(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, nil)) + h := &handler{token: "expected-session-secret", origin: "http://local.invalid", logger: logger} + request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader("{}")) + request.Header.Set("X-BD2-Session", "provided-session-secret") + request.Header.Set("Origin", "http://local.invalid") + response := httptest.NewRecorder() + h.routes().ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + logged := output.String() + for _, secret := range []string{"expected-session-secret", "provided-session-secret"} { + if strings.Contains(logged, secret) { + t.Fatalf("log contains session value %q: %s", secret, logged) + } + } +} diff --git a/go/internal/client/app/platform_darwin.go b/go/internal/client/app/platform_darwin.go new file mode 100644 index 0000000..7cf76ab --- /dev/null +++ b/go/internal/client/app/platform_darwin.go @@ -0,0 +1,65 @@ +//go:build darwin + +package app + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func ShowFatalError(err error) { + if err == nil { + return + } + message := strings.ReplaceAll(err.Error(), `"`, `\"`) + _ = exec.Command("osascript", "-e", `display alert "BD2 Client Studio" message "`+message+`" as critical`).Run() +} + +func openBrowser(url string) error { + return exec.Command("open", url).Start() +} + +func browseForGameDirectory(language string) (string, error) { + prompt := "Select the Brown Dust II.app bundle or its parent folder" + if language == "zh-CN" { + prompt = "选择 Brown Dust II.app 或其所在文件夹" + } + return macDirectoryPicker(prompt) +} + +func browseForResourceDirectory(language string) (string, error) { + prompt := "Select the CDN directory containing ServerData and GameData" + if language == "zh-CN" { + prompt = "选择包含 ServerData 和 GameData 的 CDN 目录" + } + return macDirectoryPicker(prompt) +} + +func macDirectoryPicker(prompt string) (string, error) { + prompt = strings.ReplaceAll(prompt, `"`, `\"`) + command := exec.Command("osascript", "-e", `POSIX path of (choose folder with prompt "`+prompt+`")`) + output, err := command.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return "", nil + } + return "", fmt.Errorf("open macOS directory picker: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +func launchGame(target string) error { + info, err := os.Stat(target) + if err != nil || !info.IsDir() || !strings.EqualFold(filepath.Ext(target), ".app") { + return fmt.Errorf("invalid macOS application bundle %q", target) + } + if err := exec.Command("pgrep", "-x", "BrownDust II").Run(); err == nil { + return errGameAlreadyRunning + } + return exec.Command("open", target).Start() +} diff --git a/go/internal/client/app/platform_other.go b/go/internal/client/app/platform_other.go new file mode 100644 index 0000000..610deab --- /dev/null +++ b/go/internal/client/app/platform_other.go @@ -0,0 +1,36 @@ +//go:build !windows && !darwin + +package app + +import ( + "errors" + "fmt" + "os" + "os/exec" + "runtime" +) + +func ShowFatalError(err error) { + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "BD2 Client Studio:", err) + } +} + +func openBrowser(url string) error { + if runtime.GOOS == "darwin" { + return exec.Command("open", url).Start() + } + return exec.Command("xdg-open", url).Start() +} + +func browseForGameDirectory(string) (string, error) { + return "", errors.New("the native directory picker is unavailable on this platform; enter the Windows client directory manually") +} + +func browseForResourceDirectory(string) (string, error) { + return "", errors.New("the native directory picker is unavailable on this platform; enter the resource directory manually") +} + +func launchGame(string) error { + return errors.New("the Brown Dust II client is not supported on Linux") +} diff --git a/go/internal/client/app/platform_windows.go b/go/internal/client/app/platform_windows.go new file mode 100644 index 0000000..35a9b9f --- /dev/null +++ b/go/internal/client/app/platform_windows.go @@ -0,0 +1,182 @@ +//go:build windows + +package app + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + platformUser32DLL = syscall.NewLazyDLL("user32.dll") + messageBoxW = platformUser32DLL.NewProc("MessageBoxW") + enumWindowsProc = platformUser32DLL.NewProc("EnumWindows") + getWindowThreadProcessIDProc = platformUser32DLL.NewProc("GetWindowThreadProcessId") + isWindowVisibleProc = platformUser32DLL.NewProc("IsWindowVisible") + showWindowAsyncProc = platformUser32DLL.NewProc("ShowWindowAsync") + setForegroundWindowProc = platformUser32DLL.NewProc("SetForegroundWindow") +) + +// ShowFatalError keeps startup failures visible even though the release +// executable uses the Windows GUI subsystem and therefore has no console. +func ShowFatalError(err error) { + if err == nil { + return + } + message, conversionErr := syscall.UTF16PtrFromString(fmt.Sprintf( + "BD2 Client Studio could not start:\n\n%s\n\nSee the logs directory next to bd2client.exe for details.", err, + )) + if conversionErr != nil { + return + } + title, conversionErr := syscall.UTF16PtrFromString("BD2 Client Studio") + if conversionErr != nil { + return + } + messageBoxW.Call(0, uintptr(unsafe.Pointer(message)), uintptr(unsafe.Pointer(title)), 0x10) +} + +// CREATE_NO_WINDOW prevents console-subsystem helpers such as powershell.exe +// from allocating a visible console when bd2client is built as a Windows GUI +// executable. HideWindow also covers helpers that elect to create a window +// despite inheriting no console from the parent process. +const createNoWindow = 0x08000000 + +func hiddenCommand(name string, args ...string) *exec.Cmd { + command := exec.Command(name, args...) + command.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: createNoWindow, + } + return command +} + +// visibleCommand suppresses a console allocation without hiding the GUI +// window created by the child process. It must be used for the game itself; +// hiddenCommand is reserved for background helper processes. +func visibleCommand(name string, args ...string) *exec.Cmd { + command := exec.Command(name, args...) + command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow} + return command +} + +func openBrowser(url string) error { + for _, edge := range edgeCandidates() { + if info, err := os.Stat(edge); err == nil && !info.IsDir() { + return hiddenCommand(edge, "--app="+url, "--window-size=1100,760", "--no-first-run").Start() + } + } + return hiddenCommand("rundll32.exe", "url.dll,FileProtocolHandler", url).Start() +} + +func edgeCandidates() []string { + var candidates []string + if edge, err := exec.LookPath("msedge.exe"); err == nil { + candidates = append(candidates, edge) + } + for _, root := range []string{os.Getenv("ProgramFiles(x86)"), os.Getenv("ProgramFiles"), os.Getenv("LOCALAPPDATA")} { + if root != "" { + candidates = append(candidates, filepath.Join(root, "Microsoft", "Edge", "Application", "msedge.exe")) + } + } + return candidates +} + +func browseForGameDirectory(language string) (string, error) { + title := "Select the Brown Dust II installation directory" + if language == "zh-CN" { + title = "选择 Brown Dust II 安装目录" + } + return browseForDirectory(title) +} + +func browseForResourceDirectory(language string) (string, error) { + title := "Select the CDN directory containing ServerData and GameData" + if language == "zh-CN" { + title = "选择包含 ServerData 和 GameData 的 CDN 目录" + } + return browseForDirectory(title) +} + +func launchGame(target string) error { + if processID, running, err := windowsExecutableProcessID(filepath.Base(target)); err != nil { + return err + } else if running { + if !activateProcessWindow(processID, 5*time.Second) { + return fmt.Errorf("Brown Dust II is running, but its window could not be restored") + } + return errGameAlreadyRunning + } + command := visibleCommand(target) + command.Dir = filepath.Dir(target) + if err := command.Start(); err != nil { + return err + } + // Unity creates the top-level window asynchronously. Best-effort foreground + // activation prevents the new window from opening behind Client Studio. + activateProcessWindow(uint32(command.Process.Pid), 15*time.Second) + return nil +} + +func windowsExecutableProcessID(name string) (uint32, bool, error) { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return 0, false, err + } + defer windows.CloseHandle(snapshot) + entry := windows.ProcessEntry32{Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{}))} + if err := windows.Process32First(snapshot, &entry); err != nil { + return 0, false, err + } + for { + if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), name) { + return entry.ProcessID, true, nil + } + if err := windows.Process32Next(snapshot, &entry); err != nil { + if err == windows.ERROR_NO_MORE_FILES { + return 0, false, nil + } + return 0, false, err + } + } +} + +func activateProcessWindow(processID uint32, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + if window := topLevelWindowForProcess(processID); window != 0 { + const swRestore = 9 + showWindowAsyncProc.Call(window, swRestore) + setForegroundWindowProc.Call(window) + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(100 * time.Millisecond) + } +} + +func topLevelWindowForProcess(processID uint32) uintptr { + var found uintptr + callback := syscall.NewCallback(func(window uintptr, _ uintptr) uintptr { + var owner uint32 + getWindowThreadProcessIDProc.Call(window, uintptr(unsafe.Pointer(&owner))) + visible, _, _ := isWindowVisibleProc.Call(window) + if owner == processID && visible != 0 { + found = window + return 0 + } + return 1 + }) + enumWindowsProc.Call(callback, 0) + return found +} diff --git a/go/internal/client/app/platform_windows_test.go b/go/internal/client/app/platform_windows_test.go new file mode 100644 index 0000000..fccb5bc --- /dev/null +++ b/go/internal/client/app/platform_windows_test.go @@ -0,0 +1,40 @@ +//go:build windows + +package app + +import "testing" + +func TestHiddenCommandNeverAllocatesVisibleConsole(t *testing.T) { + command := hiddenCommand("powershell.exe", "-NoProfile") + if command.SysProcAttr == nil { + t.Fatal("hidden command has no Windows process attributes") + } + if !command.SysProcAttr.HideWindow { + t.Fatal("hidden command does not request a hidden window") + } + if command.SysProcAttr.CreationFlags&createNoWindow == 0 { + t.Fatalf("hidden command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags) + } +} + +func TestVisibleCommandDoesNotHideGUIWindow(t *testing.T) { + command := visibleCommand("Brown Dust II.exe") + if command.SysProcAttr == nil { + t.Fatal("visible command has no Windows process attributes") + } + if command.SysProcAttr.HideWindow { + t.Fatal("visible game command requests a hidden window") + } + if command.SysProcAttr.CreationFlags&createNoWindow == 0 { + t.Fatalf("visible command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags) + } +} + +func TestHRESULTFailureClassification(t *testing.T) { + if hresultFailed(0) || hresultFailed(1) { + t.Fatal("successful HRESULT classified as failure") + } + if !hresultFailed(errorCancelled) || !hresultFailed(0x80004005) { + t.Fatal("failed HRESULT classified as success") + } +} diff --git a/go/internal/client/app/web/index.html b/go/internal/client/app/web/index.html new file mode 100644 index 0000000..a7f2896 --- /dev/null +++ b/go/internal/client/app/web/index.html @@ -0,0 +1,133 @@ + + + + + + + + + + +BD2 Client Studio + + + + + +
BD2 CLIENT STUDIOCLIENT CONFIGURATION
客户端设置工具 · 配置保存在本机
+
+
+
01 / GAME DIRECTORY

先从游戏目录
开始。

选择已安装的游戏文件夹。
我们会检查游戏程序和资源文件,再继续设置连接。

BrownDust II · 客户端配置
+

选择游戏目录

请选择包含 BrownDust II.exe 的文件夹。

可直接填写路径,也可以浏览目录。

此步骤只检查文件,不会修改游戏资源。

+
+ + + +
+
  1. 01选择目录
  2. 02设置服务器
  3. 03客户端工作台
BROWN DUST II · CLIENT UTILITY
+

修改设置

修改后请在工作台保存连接设置。

+
+ + + + diff --git a/go/internal/client/config/config.go b/go/internal/client/config/config.go new file mode 100644 index 0000000..12a88d8 --- /dev/null +++ b/go/internal/client/config/config.go @@ -0,0 +1,163 @@ +// Package config owns the client-side connection settings consumed by the +// standalone setup tool and the Local Identity plugin. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "strings" + + clientlayout "bd2server/internal/client/layout" +) + +const ( + SchemaVersion = 2 + FileName = "bd2.client.json" + + CDNOfficial CDNMode = "official" + CDNLocal CDNMode = "local" + CDNServer CDNMode = "server" +) + +type CDNMode string + +type Settings struct { + SchemaVersion int `json:"schema_version"` + ServerOrigin string `json:"server_origin"` + CDNMode CDNMode `json:"cdn_mode"` + LocalResourceDirectory string `json:"local_resource_directory,omitempty"` +} + +func Path(gameDir string) string { + if installation, err := clientlayout.Resolve(gameDir); err == nil { + return filepath.Join(installation.Config, FileName) + } + return filepath.Join(filepath.Clean(gameDir), "BepInEx", "config", FileName) +} + +func Normalize(in Settings) (Settings, error) { + origin, err := NormalizeOrigin(in.ServerOrigin) + if err != nil { + return Settings{}, err + } + localDirectory := strings.TrimSpace(in.LocalResourceDirectory) + switch in.CDNMode { + case CDNOfficial, CDNServer: + if localDirectory != "" { + return Settings{}, errors.New("client config: local_resource_directory is only valid in local mode") + } + case CDNLocal: + if localDirectory == "" { + return Settings{}, errors.New("client config: local mode requires local_resource_directory") + } + localDirectory, err = filepath.Abs(filepath.Clean(localDirectory)) + if err != nil { + return Settings{}, fmt.Errorf("client config: resolve local resource directory: %w", err) + } + default: + return Settings{}, fmt.Errorf("client config: unsupported CDN mode %q", in.CDNMode) + } + return Settings{ + SchemaVersion: SchemaVersion, + ServerOrigin: origin, + CDNMode: in.CDNMode, + LocalResourceDirectory: localDirectory, + }, nil +} + +func NormalizeOrigin(raw string) (string, error) { + raw = strings.TrimSpace(raw) + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("client config: parse server origin: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("client config: server address must use http or https") + } + if parsed.Host == "" { + return "", errors.New("client config: server address must include a host") + } + if parsed.User != nil { + return "", errors.New("client config: credentials are not allowed in the server address") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("client config: server address cannot contain a query or fragment") + } + if parsed.Path != "" && parsed.Path != "/" { + return "", errors.New("client config: enter only the server origin, without /game or another path") + } + if parsed.Scheme == "http" && !isLoopback(parsed.Hostname()) { + return "", errors.New("client config: non-loopback servers must use https") + } + parsed.Path = "" + parsed.RawPath = "" + return strings.TrimSuffix(parsed.String(), "/"), nil +} + +func isLoopback(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func Save(gameDir string, in Settings) (Settings, error) { + settings, err := Normalize(in) + if err != nil { + return Settings{}, err + } + path := Path(gameDir) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return Settings{}, fmt.Errorf("client config: create config directory: %w", err) + } + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return Settings{}, err + } + data = append(data, '\n') + temporary, err := os.CreateTemp(filepath.Dir(path), ".bd2-client-*.tmp") + if err != nil { + return Settings{}, fmt.Errorf("client config: create temporary config: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err = temporary.Chmod(0o600); err == nil { + _, err = temporary.Write(data) + } + if err == nil { + err = temporary.Sync() + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + return Settings{}, fmt.Errorf("client config: stage config: %w", err) + } + if err := replaceFile(temporaryPath, path); err != nil { + return Settings{}, fmt.Errorf("client config: install config: %w", err) + } + return settings, nil +} + +func Load(gameDir string) (Settings, error) { + data, err := os.ReadFile(Path(gameDir)) + if err != nil { + return Settings{}, err + } + var settings Settings + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&settings); err != nil { + return Settings{}, fmt.Errorf("client config: decode: %w", err) + } + if settings.SchemaVersion != SchemaVersion { + return Settings{}, fmt.Errorf("client config: unsupported schema_version %d", settings.SchemaVersion) + } + return Normalize(settings) +} diff --git a/go/internal/client/config/config_test.go b/go/internal/client/config/config_test.go new file mode 100644 index 0000000..c6479a5 --- /dev/null +++ b/go/internal/client/config/config_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNormalize(t *testing.T) { + got, err := Normalize(Settings{ServerOrigin: " https://example.com:8443/ ", CDNMode: CDNServer}) + if err != nil { + t.Fatal(err) + } + if got.SchemaVersion != SchemaVersion || got.ServerOrigin != "https://example.com:8443" || got.CDNMode != CDNServer { + t.Fatalf("normalized=%+v", got) + } + for _, bad := range []string{"example.com", "ftp://example.com", "http://192.168.1.8:8080", "https://u:p@example.com", "https://example.com/game/", "https://example.com?q=1"} { + if _, err := Normalize(Settings{ServerOrigin: bad, CDNMode: CDNOfficial}); err == nil { + t.Errorf("accepted origin %q", bad) + } + } + localRoot := t.TempDir() + local, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: localRoot}) + if err != nil || local.LocalResourceDirectory != localRoot { + t.Fatalf("local=%+v err=%v", local, err) + } + if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal}); err == nil { + t.Fatal("accepted local mode without a resource directory") + } + if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, LocalResourceDirectory: localRoot}); err == nil { + t.Fatal("accepted a local resource directory in official mode") + } +} + +func TestSaveLoad(t *testing.T) { + dir := t.TempDir() + want := Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: t.TempDir()} + if _, err := Save(dir, want); err != nil { + t.Fatal(err) + } + got, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if got.ServerOrigin != want.ServerOrigin || got.CDNMode != want.CDNMode || got.SchemaVersion != SchemaVersion || got.LocalResourceDirectory != want.LocalResourceDirectory { + t.Fatalf("loaded=%+v", got) + } + data, err := os.ReadFile(Path(dir)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "secret") || strings.Contains(string(data), "token") { + t.Fatalf("client config unexpectedly stores a credential: %s", data) + } + updated := Settings{ServerOrigin: "https://friends.example:8443", CDNMode: CDNServer} + if _, err := Save(dir, updated); err != nil { + t.Fatalf("replace config: %v", err) + } + got, err = Load(dir) + if err != nil || got.ServerOrigin != updated.ServerOrigin || got.CDNMode != updated.CDNMode { + t.Fatalf("replaced=%+v err=%v", got, err) + } +} + +func TestSaveOmitsLocalDirectoryOutsideLocalMode(t *testing.T) { + dir := t.TempDir() + if _, err := Save(dir, Settings{ServerOrigin: "https://example.com", CDNMode: CDNOfficial}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(Path(dir)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "local_resource_directory") { + t.Fatalf("official config contains local directory field: %s", data) + } +} + +func TestPathUsesMacAppSiblingBepInEx(t *testing.T) { + parent := t.TempDir() + app := filepath.Join(parent, "BrownDust II.app") + for _, path := range []string{ + filepath.Join(app, "Contents", "MacOS", "BrownDust II"), + filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("test"), 0o700); err != nil { + t.Fatal(err) + } + } + want := filepath.Join(parent, "BepInEx", "config", FileName) + if got := Path(app); got != want { + t.Fatalf("Path()=%q want=%q", got, want) + } +} diff --git a/go/internal/client/config/preferences.go b/go/internal/client/config/preferences.go new file mode 100644 index 0000000..db5edce --- /dev/null +++ b/go/internal/client/config/preferences.go @@ -0,0 +1,96 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const preferencesSchemaVersion = 1 + +type Preferences struct { + SchemaVersion int `json:"schema_version"` + GameDirectory string `json:"game_directory"` +} + +func PreferencesPath() (string, error) { + root, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate user configuration directory: %w", err) + } + return filepath.Join(root, "BD2 Client Studio", "preferences.json"), nil +} + +func LoadPreferences() (Preferences, error) { + path, err := PreferencesPath() + if err != nil { + return Preferences{}, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return Preferences{}, nil + } + if err != nil { + return Preferences{}, fmt.Errorf("read client preferences: %w", err) + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + var preferences Preferences + if err := decoder.Decode(&preferences); err != nil { + return Preferences{}, fmt.Errorf("decode client preferences: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return Preferences{}, errors.New("client preferences must contain exactly one JSON object") + } + if preferences.SchemaVersion != preferencesSchemaVersion || strings.TrimSpace(preferences.GameDirectory) == "" { + return Preferences{}, errors.New("client preferences are invalid") + } + preferences.GameDirectory = filepath.Clean(preferences.GameDirectory) + return preferences, nil +} + +func SavePreferences(gameDirectory string) error { + abs, err := filepath.Abs(filepath.Clean(strings.TrimSpace(gameDirectory))) + if err != nil || strings.TrimSpace(gameDirectory) == "" { + return errors.New("client preferences require a valid game directory") + } + path, err := PreferencesPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create client preferences directory: %w", err) + } + data, err := json.MarshalIndent(Preferences{SchemaVersion: preferencesSchemaVersion, GameDirectory: abs}, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + temporary, err := os.CreateTemp(filepath.Dir(path), ".preferences-*.tmp") + if err != nil { + return fmt.Errorf("stage client preferences: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err = temporary.Chmod(0o600); err == nil { + _, err = temporary.Write(data) + } + if err == nil { + err = temporary.Sync() + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + return fmt.Errorf("stage client preferences: %w", err) + } + if err := replaceFile(temporaryPath, path); err != nil { + return fmt.Errorf("install client preferences: %w", err) + } + return nil +} diff --git a/go/internal/client/config/preferences_test.go b/go/internal/client/config/preferences_test.go new file mode 100644 index 0000000..32477f6 --- /dev/null +++ b/go/internal/client/config/preferences_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPreferencesRoundTrip(t *testing.T) { + root := t.TempDir() + t.Setenv("APPDATA", root) + game := filepath.Join(root, "game") + if err := SavePreferences(game); err != nil { + t.Fatal(err) + } + got, err := LoadPreferences() + if err != nil { + t.Fatal(err) + } + want, _ := filepath.Abs(game) + if got.SchemaVersion != preferencesSchemaVersion || got.GameDirectory != want { + t.Fatalf("preferences=%+v", got) + } + path, err := PreferencesPath() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} diff --git a/go/internal/client/config/release.go b/go/internal/client/config/release.go new file mode 100644 index 0000000..6bd5ea9 --- /dev/null +++ b/go/internal/client/config/release.go @@ -0,0 +1,70 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" +) + +const ReleaseFileName = "versions.json" + +var ( + clientVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + resourceVersionPattern = regexp.MustCompile(`^[0-9]{14}$`) +) + +// ReleaseVersions is the exact client/resource tuple supported by one +// bd2client distribution. The release package carries the authoritative +// versions.json next to bd2client.exe. +type ReleaseVersions struct { + ClientVersion string `json:"client_version"` + GameDataVersion string `json:"game_data_version"` + BundleVersion string `json:"bundle_version"` + SeedDirectory string `json:"seed_directory"` + Plugins struct { + LocalIdentity string `json:"local_identity"` + CaptureEnvironment string `json:"capture_environment"` + LoginUI string `json:"login_ui"` + } `json:"plugins"` +} + +func LoadReleaseVersions(path string) (ReleaseVersions, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return ReleaseVersions{}, fmt.Errorf("read client release versions: %w", err) + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + var versions ReleaseVersions + if err := decoder.Decode(&versions); err != nil { + return ReleaseVersions{}, fmt.Errorf("decode client release versions: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return ReleaseVersions{}, errors.New("client release versions must contain exactly one JSON object") + } + if !clientVersionPattern.MatchString(versions.ClientVersion) { + return ReleaseVersions{}, fmt.Errorf("invalid client_version %q", versions.ClientVersion) + } + if !resourceVersionPattern.MatchString(versions.BundleVersion) || !resourceVersionPattern.MatchString(versions.GameDataVersion) { + return ReleaseVersions{}, errors.New("bundle_version and game_data_version must be 14-digit timestamps") + } + if versions.SeedDirectory == "" || + versions.Plugins.LocalIdentity == "" || versions.Plugins.LoginUI == "" || versions.Plugins.CaptureEnvironment == "" { + return ReleaseVersions{}, errors.New("client release versions are incomplete") + } + return versions, nil +} + +func ReleaseVersionsBesideExecutable() (ReleaseVersions, error) { + executable, err := os.Executable() + if err != nil { + return ReleaseVersions{}, fmt.Errorf("locate bd2client executable: %w", err) + } + return LoadReleaseVersions(filepath.Join(filepath.Dir(executable), ReleaseFileName)) +} diff --git a/go/internal/client/config/release_test.go b/go/internal/client/config/release_test.go new file mode 100644 index 0000000..5f8a186 --- /dev/null +++ b/go/internal/client/config/release_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadReleaseVersions(t *testing.T) { + path := filepath.Join(t.TempDir(), ReleaseFileName) + data := `{"client_version":"2.35.10","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"go/seed/v2_35_10","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}` + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + got, err := LoadReleaseVersions(path) + if err != nil { + t.Fatal(err) + } + if got.ClientVersion != "2.35.10" || got.BundleVersion != "20260921135230" || got.GameDataVersion != "20260923193640" { + t.Fatalf("versions=%+v", got) + } +} + +func TestLoadReleaseVersionsRejectsUnknownAndTrailingData(t *testing.T) { + for name, data := range map[string]string{ + "unknown": `{"client_version":"2.35.10","unknown":true}`, + "trailing": `{}` + `{}`, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), ReleaseFileName) + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadReleaseVersions(path); err == nil { + t.Fatal("accepted invalid release versions") + } + }) + } +} diff --git a/go/internal/clientplugin/replace_other.go b/go/internal/client/config/replace_other.go similarity index 86% rename from go/internal/clientplugin/replace_other.go rename to go/internal/client/config/replace_other.go index 0f064ed..3264ee6 100644 --- a/go/internal/clientplugin/replace_other.go +++ b/go/internal/client/config/replace_other.go @@ -1,6 +1,6 @@ //go:build !windows -package clientplugin +package config import "os" diff --git a/go/internal/clientplugin/replace_windows.go b/go/internal/client/config/replace_windows.go similarity index 96% rename from go/internal/clientplugin/replace_windows.go rename to go/internal/client/config/replace_windows.go index 3324733..6dd39c9 100644 --- a/go/internal/clientplugin/replace_windows.go +++ b/go/internal/client/config/replace_windows.go @@ -1,6 +1,6 @@ //go:build windows -package clientplugin +package config import ( "os" diff --git a/go/internal/client/introdb/cipher.go b/go/internal/client/introdb/cipher.go new file mode 100644 index 0000000..916df17 --- /dev/null +++ b/go/internal/client/introdb/cipher.go @@ -0,0 +1,63 @@ +package introdb + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha1" + "fmt" +) + +const PageSize = 4096 + +var Header = []byte("SQLite format 3\x00") + +func decryptPages(in []byte) ([]byte, error) { return cryptPages(in, false) } +func encryptPages(in []byte) ([]byte, error) { return cryptPages(in, true) } + +func cryptPages(in []byte, encrypt bool) ([]byte, error) { + if len(in) == 0 || len(in)%PageSize != 0 { + return nil, fmt.Errorf("dbcrypt: database length %d is not a non-zero multiple of %d", len(in), PageSize) + } + block, err := aes.NewCipher(deriveKey()) + if err != nil { + return nil, err + } + out := make([]byte, len(in)) + for start := 0; start < len(in); start += PageSize { + var mode cipher.BlockMode = cipher.NewCBCEncrypter(block, Header) + if !encrypt { + mode = cipher.NewCBCDecrypter(block, Header) + } + mode.CryptBlocks(out[start:start+PageSize], in[start:start+PageSize]) + } + return out, nil +} + +func deriveKey() []byte { + password := []byte(fmt.Sprintf("%X", sha1.Sum([]byte("spdhdnlwmrpavmtm")))) + return pbkdf2SHA1(password, Header, 2010, 32) +} + +func pbkdf2SHA1(password, salt []byte, iterations, length int) []byte { + var result []byte + for block := uint32(1); len(result) < length; block++ { + message := append(append([]byte{}, salt...), byte(block>>24), byte(block>>16), byte(block>>8), byte(block)) + u := hmacSHA1(password, message) + t := append([]byte{}, u...) + for i := 1; i < iterations; i++ { + u = hmacSHA1(password, u) + for j := range t { + t[j] ^= u[j] + } + } + result = append(result, t...) + } + return result[:length] +} + +func hmacSHA1(key, message []byte) []byte { + h := hmac.New(sha1.New, key) + _, _ = h.Write(message) + return h.Sum(nil) +} diff --git a/go/internal/introdb/introdb.go b/go/internal/client/introdb/introdb.go similarity index 96% rename from go/internal/introdb/introdb.go rename to go/internal/client/introdb/introdb.go index 54d1b36..657a89b 100644 --- a/go/internal/introdb/introdb.go +++ b/go/internal/client/introdb/introdb.go @@ -9,16 +9,15 @@ import ( "fmt" "io" "os" - "path/filepath" - "bd2server/internal/dbcrypt" + clientlayout "bd2server/internal/client/layout" ) const ( oldURL = "https://mt.bd2.pmang.cloud/" ) -var salt = dbcrypt.Header +var salt = Header // Result describes a completed in-place client patch. BackupPath is the // immutable pre-patch copy and is never overwritten by a later invocation. @@ -29,6 +28,7 @@ type Result struct { ObjectSize uint32 OldURL string NewURL string + Changed bool } // VerifyResult is useful to patch-client's --verify mode and to diagnostics. @@ -74,7 +74,7 @@ func PatchClient(gameDir, newURL string) (Result, error) { return Result{}, findErr } if current == newURL { - return Result{assets, assets + ".bak", entry.pathID, entry.size, current, newURL}, nil + return Result{assets, assets + ".bak", entry.pathID, entry.size, current, newURL, false}, nil } return Result{}, fmt.Errorf("introdb: LIVE_URL is already %q, not the expected official URL", current) } @@ -112,7 +112,7 @@ func PatchClient(gameDir, newURL string) (Result, error) { if err := atomicWrite(assets, b); err != nil { return Result{}, err } - return Result{assets, backup, entry.pathID, entry.size, oldURL, newURL}, nil + return Result{assets, backup, entry.pathID, entry.size, oldURL, newURL, true}, nil } // VerifyClient reads and decrypts the embedded Intro TextAsset. It verifies @@ -152,7 +152,11 @@ func ResourcesPath(gameDir string) (string, error) { if gameDir == "" { return "", errors.New("introdb: empty game directory") } - p := filepath.Join(gameDir, "BrownDust II_Data", "resources.assets") + installation, err := clientlayout.Resolve(gameDir) + if err != nil { + return "", fmt.Errorf("introdb: resolve game layout: %w", err) + } + p := installation.Resources st, err := os.Stat(p) if err != nil { return "", fmt.Errorf("introdb: resources.assets not found at %q: %w", p, err) @@ -164,10 +168,10 @@ func ResourcesPath(gameDir string) (string, error) { } // DecryptPages decrypts the game's independent 4096-byte AES-CBC pages. -func DecryptPages(in []byte) ([]byte, error) { return dbcrypt.DecryptPages(in) } +func DecryptPages(in []byte) ([]byte, error) { return decryptPages(in) } // EncryptPages encrypts the game's independent 4096-byte AES-CBC pages. -func EncryptPages(in []byte) ([]byte, error) { return dbcrypt.EncryptPages(in) } +func EncryptPages(in []byte) ([]byte, error) { return encryptPages(in) } func validateIntroDB(p []byte, expected string) error { if !bytes.HasPrefix(p, salt) { diff --git a/go/internal/introdb/introdb_test.go b/go/internal/client/introdb/introdb_test.go similarity index 91% rename from go/internal/introdb/introdb_test.go rename to go/internal/client/introdb/introdb_test.go index 84d72a6..055862f 100644 --- a/go/internal/introdb/introdb_test.go +++ b/go/internal/client/introdb/introdb_test.go @@ -5,8 +5,6 @@ import ( "os" "path/filepath" "testing" - - "bd2server/internal/dbcrypt" ) func referenceClientDir(t *testing.T) string { @@ -19,7 +17,7 @@ func referenceClientDir(t *testing.T) string { } func TestPagesRoundTrip(t *testing.T) { - p := make([]byte, dbcrypt.PageSize*2) + p := make([]byte, PageSize*2) copy(p, salt) for i := 16; i < len(p); i++ { p[i] = byte(i * 31) @@ -37,7 +35,7 @@ func TestPagesRoundTrip(t *testing.T) { } } func TestPagesRejectPartialPage(t *testing.T) { - if _, err := DecryptPages(make([]byte, dbcrypt.PageSize-1)); err == nil { + if _, err := DecryptPages(make([]byte, PageSize-1)); err == nil { t.Fatal("accepted partial page") } } @@ -109,6 +107,9 @@ func TestPatchClientTransaction(t *testing.T) { if r.OldURL != oldURL || r.NewURL != local || r.BackupPath != dst+".bak" { t.Fatalf("unexpected patch result: %#v", r) } + if !r.Changed { + t.Fatal("first patch was not reported as changed") + } if _, err := os.Stat(r.BackupPath); err != nil { t.Fatalf("backup missing: %v", err) } @@ -119,7 +120,11 @@ func TestPatchClientTransaction(t *testing.T) { if v.URL != local { t.Fatalf("LIVE_URL=%q, want %q", v.URL, local) } - if _, err := PatchClient(tmp, local); err != nil { + repeated, err := PatchClient(tmp, local) + if err != nil { t.Fatalf("idempotent patch: %v", err) } + if repeated.Changed { + t.Fatal("idempotent patch was reported as changed") + } } diff --git a/go/internal/client/layout/layout.go b/go/internal/client/layout/layout.go new file mode 100644 index 0000000..235d02a --- /dev/null +++ b/go/internal/client/layout/layout.go @@ -0,0 +1,120 @@ +// Package layout resolves the supported Windows and macOS Brown Dust II +// installation layouts without relying on the host running bd2client. +package layout + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +type Kind string + +const ( + Windows Kind = "windows" + MacOS Kind = "macos" +) + +type Installation struct { + Kind Kind + Selected string + Root string + Executable string + Data string + Resources string + Managers string + BepInEx string + Config string + Plugins string + Disabled string +} + +func Resolve(selected string) (Installation, error) { + if strings.TrimSpace(selected) == "" { + return Installation{}, errors.New("select the Brown Dust II installation directory") + } + abs, err := filepath.Abs(strings.TrimSpace(selected)) + if err != nil { + return Installation{}, fmt.Errorf("resolve game directory: %w", err) + } + abs = filepath.Clean(abs) + if installation, ok := windowsLayout(abs); ok { + return installation, nil + } + if installation, ok := macLayout(abs); ok { + return installation, nil + } + return Installation{}, errors.New("the selected directory is not a complete Brown Dust II Windows or macOS client") +} + +func windowsLayout(root string) (Installation, bool) { + executable := filepath.Join(root, "BrownDust II.exe") + data := filepath.Join(root, "BrownDust II_Data") + if !regularFile(executable) || !regularFile(filepath.Join(data, "resources.assets")) { + return Installation{}, false + } + return newInstallation(Windows, root, root, executable, data, filepath.Join(root, "BepInEx")), true +} + +func macLayout(selected string) (Installation, bool) { + candidates := []string{selected} + if !strings.EqualFold(filepath.Ext(selected), ".app") { + candidates = append(candidates, filepath.Join(selected, "BrownDust II.app")) + } + for _, app := range candidates { + contents := filepath.Join(app, "Contents") + executable := filepath.Join(contents, "MacOS", "BrownDust II") + data := filepath.Join(contents, "Resources", "Data") + if regularFile(executable) && regularFile(filepath.Join(data, "resources.assets")) { + // BepInEx Unix distributions are normally extracted beside the + // .app bundle. Also accept an installation placed inside Contents. + bepInEx := filepath.Join(filepath.Dir(app), "BepInEx") + insideBundle := filepath.Join(contents, "BepInEx") + if directoryExists(insideBundle) && !directoryExists(bepInEx) { + bepInEx = insideBundle + } + return newInstallation(MacOS, selected, app, executable, data, bepInEx), true + } + } + return Installation{}, false +} + +func newInstallation(kind Kind, selected, root, executable, data, bepInEx string) Installation { + return Installation{ + Kind: kind, + Selected: selected, + Root: root, + Executable: executable, + Data: data, + Resources: filepath.Join(data, "resources.assets"), + Managers: filepath.Join(data, "globalgamemanagers"), + BepInEx: bepInEx, + Config: filepath.Join(bepInEx, "config"), + Plugins: filepath.Join(bepInEx, "plugins"), + Disabled: filepath.Join(bepInEx, "disabled"), + } +} + +func (i Installation) LaunchTarget() string { + if i.Kind == MacOS { + return i.Root + } + return i.Executable +} + +func (i Installation) SupportedOnHost() bool { + return (i.Kind == Windows && runtime.GOOS == "windows") || (i.Kind == MacOS && runtime.GOOS == "darwin") +} + +func regularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func directoryExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/go/internal/client/layout/layout_test.go b/go/internal/client/layout/layout_test.go new file mode 100644 index 0000000..92a4d4a --- /dev/null +++ b/go/internal/client/layout/layout_test.go @@ -0,0 +1,40 @@ +package layout + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("test"), 0o700); err != nil { + t.Fatal(err) + } +} + +func TestResolveWindows(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "BrownDust II.exe")) + writeFile(t, filepath.Join(root, "BrownDust II_Data", "resources.assets")) + got, err := Resolve(root) + if err != nil || got.Kind != Windows || got.Resources != filepath.Join(root, "BrownDust II_Data", "resources.assets") || got.Plugins != filepath.Join(root, "BepInEx", "plugins") { + t.Fatalf("layout=%+v err=%v", got, err) + } +} + +func TestResolveMacAppAndParent(t *testing.T) { + parent := t.TempDir() + app := filepath.Join(parent, "BrownDust II.app") + writeFile(t, filepath.Join(app, "Contents", "MacOS", "BrownDust II")) + writeFile(t, filepath.Join(app, "Contents", "Resources", "Data", "resources.assets")) + for _, selected := range []string{app, parent} { + got, err := Resolve(selected) + if err != nil || got.Kind != MacOS || got.Root != app || got.BepInEx != filepath.Join(parent, "BepInEx") || got.LaunchTarget() != app { + t.Fatalf("selected=%q layout=%+v err=%v", selected, got, err) + } + } +} diff --git a/go/internal/clientplugin/install.go b/go/internal/client/plugin/install.go similarity index 58% rename from go/internal/clientplugin/install.go rename to go/internal/client/plugin/install.go index a3b0942..07656a8 100644 --- a/go/internal/clientplugin/install.go +++ b/go/internal/client/plugin/install.go @@ -1,4 +1,4 @@ -package clientplugin +package plugin import ( "bytes" @@ -8,55 +8,80 @@ import ( "io" "os" "path/filepath" + + clientlayout "bd2server/internal/client/layout" ) const ( - FileName = "BD2LocalIdentity.dll" BepInExReleasesURL = "https://github.com/BepInEx/BepInEx/releases" ) +type Spec struct { + fileName string +} + +var ( + LocalIdentity = Spec{fileName: "BD2LocalIdentity.dll"} + LoginUI = Spec{fileName: "BD2LoginUI.dll"} +) + +func (s Spec) FileName() string { return s.fileName } + +func (s Spec) validate() error { + if s.fileName == "" || filepath.Base(s.fileName) != s.fileName || filepath.Ext(s.fileName) != ".dll" { + return errors.New("clientplugin: invalid plugin specification") + } + return nil +} + type Result struct { Destination string Changed bool } -func ResolvePackaged(explicit string) (string, error) { +func ResolvePackaged(spec Spec, explicit string) (string, error) { + if err := spec.validate(); err != nil { + return "", err + } if explicit != "" { return filepath.Clean(explicit), nil } executable, err := os.Executable() if err != nil { - return "", fmt.Errorf("clientplugin: resolve server executable: %w", err) + return "", fmt.Errorf("clientplugin: resolve client tool executable: %w", err) } - return filepath.Join(filepath.Dir(executable), "plugins", FileName), nil + return filepath.Join(filepath.Dir(executable), "plugins", spec.fileName), nil } // Install verifies that the user installed BepInEx, then atomically stages the -// packaged local-identity plugin into its plugins directory. It never installs -// or downloads BepInEx itself. -func Install(gameDir, source string) (Result, error) { +// packaged plugin into its plugins directory. It never installs or downloads +// BepInEx itself. +func Install(spec Spec, gameDir, source string) (Result, error) { + if err := spec.validate(); err != nil { + return Result{}, err + } if gameDir == "" || source == "" { return Result{}, errors.New("clientplugin: game directory and plugin source are required") } gameDir = filepath.Clean(gameDir) source = filepath.Clean(source) - gameExecutable := filepath.Join(gameDir, "BrownDust II.exe") - if info, err := os.Stat(gameExecutable); err != nil || info.IsDir() { - return Result{}, fmt.Errorf("clientplugin: game executable is unavailable at %q", gameExecutable) + installation, err := clientlayout.Resolve(gameDir) + if err != nil { + return Result{}, fmt.Errorf("clientplugin: resolve game layout: %w", err) } - bepInEx := filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll") + bepInEx := filepath.Join(installation.BepInEx, "core", "BepInEx.dll") if info, err := os.Stat(bepInEx); err != nil || info.IsDir() { - return Result{}, fmt.Errorf("clientplugin: BepInEx is not installed; install it manually from %s, then restart the server; %s was not copied", BepInExReleasesURL, FileName) + return Result{}, fmt.Errorf("clientplugin: BepInEx is not installed; install it manually from %s, then run the client tool again; %s was not copied", BepInExReleasesURL, spec.fileName) } sourceData, err := os.ReadFile(source) if err != nil { - return Result{}, fmt.Errorf("clientplugin: read packaged %s: %w", FileName, err) + return Result{}, fmt.Errorf("clientplugin: read packaged %s: %w", spec.fileName, err) } if len(sourceData) == 0 { - return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", FileName) + return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", spec.fileName) } - pluginDir := filepath.Join(gameDir, "BepInEx", "plugins") - destination := filepath.Join(pluginDir, FileName) + pluginDir := installation.Plugins + destination := filepath.Join(pluginDir, spec.fileName) if installed, err := os.ReadFile(destination); err == nil { if bytes.Equal(hash(installed), hash(sourceData)) { return Result{Destination: destination}, nil @@ -67,7 +92,7 @@ func Install(gameDir, source string) (Result, error) { if err := os.MkdirAll(pluginDir, 0o755); err != nil { return Result{}, fmt.Errorf("clientplugin: create plugin directory: %w", err) } - temporary, err := os.CreateTemp(pluginDir, ".BD2LocalIdentity-*.tmp") + temporary, err := os.CreateTemp(pluginDir, "."+spec.fileName+"-*.tmp") if err != nil { return Result{}, fmt.Errorf("clientplugin: create temporary plugin: %w", err) } diff --git a/go/internal/client/plugin/install_test.go b/go/internal/client/plugin/install_test.go new file mode 100644 index 0000000..280e365 --- /dev/null +++ b/go/internal/client/plugin/install_test.go @@ -0,0 +1,113 @@ +package plugin + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) { + for _, spec := range []Spec{LocalIdentity, LoginUI} { + t.Run(spec.FileName(), func(t *testing.T) { + gameDir := t.TempDir() + for path, data := range map[string][]byte{ + filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"), + filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + source := filepath.Join(t.TempDir(), spec.FileName()) + if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil { + t.Fatal(err) + } + _, err := Install(spec, gameDir, source) + if err == nil || !strings.Contains(err.Error(), BepInExReleasesURL) { + t.Fatalf("missing BepInEx error=%v", err) + } + if _, statErr := os.Stat(filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())); !os.IsNotExist(statErr) { + t.Fatalf("plugin was copied without BepInEx: %v", statErr) + } + }) + } +} + +func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) { + for _, spec := range []Spec{LocalIdentity, LoginUI} { + t.Run(spec.FileName(), func(t *testing.T) { + gameDir := t.TempDir() + for path, data := range map[string][]byte{ + filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"), + filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"), + filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + source := filepath.Join(t.TempDir(), spec.FileName()) + if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil { + t.Fatal(err) + } + first, err := Install(spec, gameDir, source) + if err != nil || !first.Changed { + t.Fatalf("first install=%+v err=%v", first, err) + } + second, err := Install(spec, gameDir, source) + if err != nil || second.Changed { + t.Fatalf("idempotent install=%+v err=%v", second, err) + } + if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil { + t.Fatal(err) + } + third, err := Install(spec, gameDir, source) + if err != nil || !third.Changed { + t.Fatalf("update=%+v err=%v", third, err) + } + got, err := os.ReadFile(third.Destination) + if err != nil || string(got) != "v2" { + t.Fatalf("installed=%q err=%v", got, err) + } + }) + } +} + +func TestInstallKeepsPluginsSeparate(t *testing.T) { + gameDir := t.TempDir() + for path, data := range map[string][]byte{ + filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"), + filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"), + filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + for _, spec := range []Spec{LocalIdentity, LoginUI} { + source := filepath.Join(t.TempDir(), spec.FileName()) + if err := os.WriteFile(source, []byte(spec.FileName()), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Install(spec, gameDir, source); err != nil { + t.Fatal(err) + } + } + for _, spec := range []Spec{LocalIdentity, LoginUI} { + path := filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName()) + data, err := os.ReadFile(path) + if err != nil || string(data) != spec.FileName() { + t.Fatalf("%s=%q err=%v", spec.FileName(), data, err) + } + } +} diff --git a/go/internal/client/plugin/replace_other.go b/go/internal/client/plugin/replace_other.go new file mode 100644 index 0000000..fef7aad --- /dev/null +++ b/go/internal/client/plugin/replace_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package plugin + +import "os" + +func replaceFile(source, destination string) error { + return os.Rename(source, destination) +} diff --git a/go/internal/client/plugin/replace_windows.go b/go/internal/client/plugin/replace_windows.go new file mode 100644 index 0000000..fe6fdd3 --- /dev/null +++ b/go/internal/client/plugin/replace_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package plugin + +import ( + "os" + "syscall" + "unsafe" +) + +var moveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW") + +func replaceFile(source, destination string) error { + sourcePtr, err := syscall.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPtr, err := syscall.UTF16PtrFromString(destination) + if err != nil { + return err + } + result, _, callErr := moveFileEx.Call( + uintptr(unsafe.Pointer(sourcePtr)), + uintptr(unsafe.Pointer(destinationPtr)), + 0x1|0x8, // MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH + ) + if result == 0 { + if callErr != syscall.Errno(0) { + return callErr + } + return os.ErrInvalid + } + return nil +} diff --git a/go/internal/client/setup/setup.go b/go/internal/client/setup/setup.go new file mode 100644 index 0000000..50cb622 --- /dev/null +++ b/go/internal/client/setup/setup.go @@ -0,0 +1,323 @@ +// Package setup implements the filesystem and network operations exposed by +// bd2client. It contains no server runtime dependencies. +package setup + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + clientconfig "bd2server/internal/client/config" + "bd2server/internal/client/introdb" + clientlayout "bd2server/internal/client/layout" + "bd2server/internal/client/plugin" +) + +const PatchPlaceholder = "http://127.0.0.1:8080/game/" + +type GameStatus struct { + GameDirectory string `json:"game_directory"` + ClientVersion string `json:"client_version"` + Executable bool `json:"executable"` + Resources bool `json:"resources"` + BepInEx bool `json:"bepinex"` + Config bool `json:"config"` + PatchedURL string `json:"patched_url,omitempty"` +} + +var clientVersionPattern = regexp.MustCompile(`(?:^|\x00)([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,3})(?:\x00)`) + +type ResourcePolicy struct { + Mode clientconfig.CDNMode `json:"mode"` + ServerDataURL string `json:"server_data_url"` + GameDataURL string `json:"game_data_url"` + BundleVersion string `json:"bundle_version"` + GameDataVersion string `json:"game_data_version"` + LocalDirectory string `json:"local_directory,omitempty"` +} + +type InstallResult struct { + LocalIdentity plugin.Result `json:"local_identity"` + LoginUI plugin.Result `json:"login_ui"` +} + +func Inspect(gameDir string, versions clientconfig.ReleaseVersions) (GameStatus, error) { + if strings.TrimSpace(gameDir) == "" { + return GameStatus{}, errors.New("select the Brown Dust II installation directory") + } + installation, err := clientlayout.Resolve(gameDir) + if err != nil { + return GameStatus{}, err + } + status := GameStatus{GameDirectory: installation.Root} + status.Executable = regularFile(installation.Executable) + status.Resources = regularFile(installation.Resources) + status.BepInEx = regularFile(filepath.Join(installation.BepInEx, "core", "BepInEx.dll")) + status.Config = regularFile(filepath.Join(installation.Config, clientconfig.FileName)) + status.ClientVersion, err = detectClientVersion(installation.Managers) + if err != nil { + return status, err + } + if status.ClientVersion != versions.ClientVersion { + return status, fmt.Errorf("unsupported Brown Dust II client version %s; this bd2client release requires %s", status.ClientVersion, versions.ClientVersion) + } + if verified, verifyErr := introdb.VerifyClient(installation.Root); verifyErr == nil { + status.PatchedURL = verified.URL + } + return status, nil +} + +func SaveSettings(gameDir string, settings clientconfig.Settings, versions clientconfig.ReleaseVersions) (clientconfig.Settings, error) { + if _, err := Inspect(gameDir, versions); err != nil { + return clientconfig.Settings{}, err + } + return clientconfig.Save(gameDir, settings) +} + +func Patch(gameDir string, settings clientconfig.Settings, versions clientconfig.ReleaseVersions) (introdb.Result, error) { + if _, err := SaveSettings(gameDir, settings, versions); err != nil { + return introdb.Result{}, err + } + result, err := introdb.PatchClient(gameDir, PatchPlaceholder) + if err != nil { + return introdb.Result{}, err + } + if _, err := introdb.VerifyClient(gameDir); err != nil { + return introdb.Result{}, fmt.Errorf("verify patched client resources: %w", err) + } + if _, err := disableLegacyPlugin(gameDir); err != nil { + return introdb.Result{}, err + } + return result, nil +} + +func InstallPlugins( + gameDir string, + settings clientconfig.Settings, + versions clientconfig.ReleaseVersions, + localIdentitySource string, + loginUISource string, +) (InstallResult, error) { + status, err := Inspect(gameDir, versions) + if err != nil { + return InstallResult{}, err + } + if !status.BepInEx { + return InstallResult{}, fmt.Errorf("BepInEx is not installed; install it from %s before installing the plugins", plugin.BepInExReleasesURL) + } + if _, err := clientconfig.Save(gameDir, settings); err != nil { + return InstallResult{}, err + } + localSource, err := plugin.ResolvePackaged(plugin.LocalIdentity, localIdentitySource) + if err != nil { + return InstallResult{}, err + } + loginSource, err := plugin.ResolvePackaged(plugin.LoginUI, loginUISource) + if err != nil { + return InstallResult{}, err + } + local, err := plugin.Install(plugin.LocalIdentity, gameDir, localSource) + if err != nil { + return InstallResult{}, err + } + login, err := plugin.Install(plugin.LoginUI, gameDir, loginSource) + if err != nil { + return InstallResult{}, err + } + return InstallResult{LocalIdentity: local, LoginUI: login}, nil +} + +func FetchResourcePolicy(ctx context.Context, client *http.Client, settings clientconfig.Settings, versions clientconfig.ReleaseVersions) (ResourcePolicy, error) { + normalized, err := clientconfig.Normalize(settings) + if err != nil { + return ResourcePolicy{}, err + } + if normalized.CDNMode == clientconfig.CDNOfficial { + return ResourcePolicy{Mode: clientconfig.CDNOfficial}, nil + } + if normalized.CDNMode == clientconfig.CDNLocal { + root, err := inspectLocalResourceDirectory(normalized.LocalResourceDirectory, versions) + if err != nil { + return ResourcePolicy{}, err + } + return ResourcePolicy{ + Mode: clientconfig.CDNLocal, + ServerDataURL: localResourceURL(filepath.Join(root, "ServerData")), + GameDataURL: localResourceURL(filepath.Join(root, "GameData")), + BundleVersion: versions.BundleVersion, + GameDataVersion: versions.GameDataVersion, + LocalDirectory: root, + }, nil + } + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + body, err := json.Marshal(map[string]clientconfig.CDNMode{"cdn_mode": normalized.CDNMode}) + if err != nil { + return ResourcePolicy{}, err + } + endpoint := normalized.ServerOrigin + "/client/resources" + request, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) + if err != nil { + return ResourcePolicy{}, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + response, err := client.Do(request) + if err != nil { + return ResourcePolicy{}, fmt.Errorf("request server resource policy: %w", err) + } + defer response.Body.Close() + limited := io.LimitReader(response.Body, 64<<10) + responseBody, err := io.ReadAll(limited) + if err != nil { + return ResourcePolicy{}, fmt.Errorf("read server resource policy response: %w", err) + } + if response.StatusCode != http.StatusOK { + message := strings.TrimSpace(string(responseBody)) + if len(message) > 300 { + message = message[:300] + } + return ResourcePolicy{}, fmt.Errorf("server rejected CDN mode %s (HTTP %d): %s", normalized.CDNMode, response.StatusCode, message) + } + var policy ResourcePolicy + if err := json.Unmarshal(responseBody, &policy); err != nil { + return ResourcePolicy{}, fmt.Errorf("decode server resource policy: %w", err) + } + if policy.Mode != normalized.CDNMode { + return ResourcePolicy{}, fmt.Errorf("server returned CDN mode %q, expected %q", policy.Mode, normalized.CDNMode) + } + if err := validatePublicURL("ServerData", policy.ServerDataURL); err != nil { + return ResourcePolicy{}, err + } + if err := validatePublicURL("GameData", policy.GameDataURL); err != nil { + return ResourcePolicy{}, err + } + if policy.BundleVersion == "" || policy.GameDataVersion == "" { + return ResourcePolicy{}, errors.New("server resource policy is missing bundle_version or game_data_version") + } + if policy.BundleVersion != versions.BundleVersion || policy.GameDataVersion != versions.GameDataVersion { + return ResourcePolicy{}, fmt.Errorf( + "server resource versions do not match this client release: bundle=%s (want %s), GameData=%s (want %s)", + policy.BundleVersion, versions.BundleVersion, policy.GameDataVersion, versions.GameDataVersion, + ) + } + return policy, nil +} + +func inspectLocalResourceDirectory(raw string, versions clientconfig.ReleaseVersions) (string, error) { + root, err := filepath.Abs(filepath.Clean(strings.TrimSpace(raw))) + if err != nil { + return "", fmt.Errorf("resolve local resource directory: %w", err) + } + for _, relative := range []string{ + filepath.Join("ServerData", "StandaloneWindows64", "HD", versions.BundleVersion, "catalog_alpha.json"), + filepath.Join("ServerData", "StandaloneWindows64", "HD", versions.BundleVersion, "catalog_alpha.hash"), + filepath.Join("GameData", versions.GameDataVersion, "release", "common-dbdata.info"), + filepath.Join("GameData", versions.GameDataVersion, "release", "common-dbdata.bin"), + } { + info, statErr := os.Stat(filepath.Join(root, relative)) + if statErr != nil || !info.Mode().IsRegular() { + return "", fmt.Errorf("local resource directory is missing %s", relative) + } + } + return root, nil +} + +func detectClientVersion(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("read Brown Dust II client version metadata: %w", err) + } + if !info.Mode().IsRegular() { + return "", errors.New("Brown Dust II client version metadata is not a regular file") + } + if info.Size() <= 0 || info.Size() > 64<<20 { + return "", fmt.Errorf("Brown Dust II client version metadata has an invalid size: %d", info.Size()) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read Brown Dust II client version metadata: %w", err) + } + matches := clientVersionPattern.FindAllSubmatch(data, -1) + versions := make(map[string]struct{}) + for _, match := range matches { + versions[string(match[1])] = struct{}{} + } + if len(versions) != 1 { + return "", fmt.Errorf("could not identify one unambiguous Brown Dust II client version in %s", path) + } + for version := range versions { + return version, nil + } + panic("unreachable") +} + +func localResourceURL(path string) string { + slashed := filepath.ToSlash(filepath.Clean(path)) + if filepath.VolumeName(path) != "" && !strings.HasPrefix(slashed, "/") { + slashed = "/" + slashed + } + return (&url.URL{Scheme: "file", Path: slashed}).String() +} + +func validatePublicURL(name, raw string) error { + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("server returned an invalid %s URL", name) + } + if parsed.Scheme == "http" && !resourceLoopback(parsed.Hostname()) { + return fmt.Errorf("server returned an invalid %s URL", name) + } + return nil +} + +func resourceLoopback(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func regularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func disableLegacyPlugin(gameDir string) (string, error) { + installation, err := clientlayout.Resolve(gameDir) + if err != nil { + return "", err + } + source := filepath.Join(installation.Plugins, "PluginLocalRes.dll") + destination := filepath.Join(installation.Disabled, "PluginLocalRes.dll") + if _, err := os.Stat(source); errors.Is(err, os.ErrNotExist) { + return "", nil + } else if err != nil { + return "", fmt.Errorf("inspect legacy local resource plugin: %w", err) + } + if _, err := os.Stat(destination); err == nil { + return "", errors.New("the legacy local resource plugin exists in both active and disabled directories; remove one copy manually") + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return "", err + } + if err := os.Rename(source, destination); err != nil { + return "", fmt.Errorf("disable legacy local resource plugin: %w", err) + } + return destination, nil +} diff --git a/go/internal/client/setup/setup_test.go b/go/internal/client/setup/setup_test.go new file mode 100644 index 0000000..a0f86b8 --- /dev/null +++ b/go/internal/client/setup/setup_test.go @@ -0,0 +1,173 @@ +package setup + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + clientconfig "bd2server/internal/client/config" +) + +func testVersions() clientconfig.ReleaseVersions { + return clientconfig.ReleaseVersions{ + ClientVersion: "2.35.10", BundleVersion: "20260921135230", GameDataVersion: "20260923193640", + } +} + +func TestInspectRequiresGameFiles(t *testing.T) { + dir := t.TempDir() + if _, err := Inspect(dir, testVersions()); err == nil { + t.Fatal("accepted empty directory") + } + for path, data := range map[string][]byte{ + filepath.Join(dir, "BrownDust II.exe"): []byte("exe"), + filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"), + filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + status, err := Inspect(dir, testVersions()) + if err != nil { + t.Fatal(err) + } + if !status.Executable || !status.Resources || status.BepInEx { + t.Fatalf("status=%+v", status) + } +} + +func TestInspectRejectsUnsupportedClientVersion(t *testing.T) { + dir := t.TempDir() + for path, data := range map[string][]byte{ + filepath.Join(dir, "BrownDust II.exe"): []byte("exe"), + filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"), + filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.36.0\x00"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + status, err := Inspect(dir, testVersions()) + if err == nil || status.ClientVersion != "2.36.0" { + t.Fatalf("status=%+v err=%v", status, err) + } +} + +func TestInspectMacApp(t *testing.T) { + parent := t.TempDir() + app := filepath.Join(parent, "BrownDust II.app") + for path, data := range map[string][]byte{ + filepath.Join(app, "Contents", "MacOS", "BrownDust II"): []byte("binary"), + filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"): []byte("assets"), + filepath.Join(app, "Contents", "Resources", "Data", "globalgamemanagers"): []byte("\x002.35.10\x00"), + filepath.Join(parent, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o700); err != nil { + t.Fatal(err) + } + } + status, err := Inspect(app, testVersions()) + if err != nil || status.GameDirectory != app || status.ClientVersion != "2.35.10" || !status.BepInEx { + t.Fatalf("status=%+v err=%v", status, err) + } +} + +func TestFetchResourcePolicyRejectsServerVersionMismatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(ResourcePolicy{ + Mode: clientconfig.CDNServer, ServerDataURL: "https://cdn.example/ServerData", + GameDataURL: "https://cdn.example/GameData", BundleVersion: "wrong", GameDataVersion: "wrong", + }) + })) + defer server.Close() + _, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{ServerOrigin: server.URL, CDNMode: clientconfig.CDNServer}, testVersions()) + if err == nil { + t.Fatal("accepted mismatched server resource versions") + } +} + +func TestFetchResourcePolicy(t *testing.T) { + var method string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method = r.Method + var request map[string]string + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + if request["cdn_mode"] != "server" { + t.Errorf("request=%v", request) + } + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(ResourcePolicy{ + Mode: clientconfig.CDNServer, + ServerDataURL: "https://cdn.example/ServerData", + GameDataURL: "https://cdn.example/GameData", + BundleVersion: "20260921135230", + GameDataVersion: "20260923193640", + }) + })) + defer server.Close() + policy, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{ + ServerOrigin: server.URL, + CDNMode: clientconfig.CDNServer, + }, testVersions()) + if err != nil { + t.Fatal(err) + } + if method != http.MethodPut || policy.Mode != clientconfig.CDNServer || policy.BundleVersion != "20260921135230" { + t.Fatalf("method=%s policy=%+v", method, policy) + } +} + +func TestLocalResourcesDoNotContactServer(t *testing.T) { + root := t.TempDir() + for _, relative := range []string{ + filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.json"), + filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.hash"), + filepath.Join("GameData", "20260923193640", "release", "common-dbdata.info"), + filepath.Join("GameData", "20260923193640", "release", "common-dbdata.bin"), + } { + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("resource"), 0o600); err != nil { + t.Fatal(err) + } + } + policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ + ServerOrigin: "http://127.0.0.1:8080", + CDNMode: clientconfig.CDNLocal, + LocalResourceDirectory: root, + }, testVersions()) + if err != nil { + t.Fatal(err) + } + if policy.Mode != clientconfig.CDNLocal || policy.LocalDirectory != root || policy.ServerDataURL == "" || policy.GameDataURL == "" { + t.Fatalf("policy=%+v", policy) + } +} + +func TestOfficialDoesNotContactServer(t *testing.T) { + policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ + ServerOrigin: "https://example.com", + CDNMode: clientconfig.CDNOfficial, + }, testVersions()) + if err != nil || policy.Mode != clientconfig.CDNOfficial { + t.Fatalf("policy=%+v err=%v", policy, err) + } +} diff --git a/go/internal/clientplugin/install_test.go b/go/internal/clientplugin/install_test.go deleted file mode 100644 index fda6077..0000000 --- a/go/internal/clientplugin/install_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package clientplugin - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) { - gameDir := t.TempDir() - if err := os.WriteFile(filepath.Join(gameDir, "BrownDust II.exe"), []byte("game"), 0o600); err != nil { - t.Fatal(err) - } - source := filepath.Join(t.TempDir(), FileName) - if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil { - t.Fatal(err) - } - _, err := Install(gameDir, source) - if err == nil || !strings.Contains(err.Error(), BepInExReleasesURL) { - t.Fatalf("missing BepInEx error=%v", err) - } - if _, statErr := os.Stat(filepath.Join(gameDir, "BepInEx", "plugins", FileName)); !os.IsNotExist(statErr) { - t.Fatalf("plugin was copied without BepInEx: %v", statErr) - } -} - -func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) { - gameDir := t.TempDir() - for path, data := range map[string][]byte{ - filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"), - filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"), - } { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0o600); err != nil { - t.Fatal(err) - } - } - source := filepath.Join(t.TempDir(), FileName) - if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil { - t.Fatal(err) - } - first, err := Install(gameDir, source) - if err != nil || !first.Changed { - t.Fatalf("first install=%+v err=%v", first, err) - } - second, err := Install(gameDir, source) - if err != nil || second.Changed { - t.Fatalf("idempotent install=%+v err=%v", second, err) - } - if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil { - t.Fatal(err) - } - third, err := Install(gameDir, source) - if err != nil || !third.Changed { - t.Fatalf("update=%+v err=%v", third, err) - } - got, err := os.ReadFile(third.Destination) - if err != nil || string(got) != "v2" { - t.Fatalf("installed=%q err=%v", got, err) - } -} diff --git a/go/internal/account/login.go b/go/internal/server/account/login.go similarity index 98% rename from go/internal/account/login.go rename to go/internal/server/account/login.go index 4a18fda..b596e15 100644 --- a/go/internal/account/login.go +++ b/go/internal/server/account/login.go @@ -12,12 +12,12 @@ import ( "path/filepath" "time" - "bd2server/internal/cryptox" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/cryptox" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) -func ProtocolVersion() string { return versionconfig.Protocol() } +func StateVersion() string { return versionconfig.State() } var ( ErrInvalidSeed = errors.New("account: invalid LoginUser seed") diff --git a/go/internal/account/login_test.go b/go/internal/server/account/login_test.go similarity index 89% rename from go/internal/account/login_test.go rename to go/internal/server/account/login_test.go index 0ee7b15..05fecaa 100644 --- a/go/internal/account/login_test.go +++ b/go/internal/server/account/login_test.go @@ -7,15 +7,15 @@ import ( "testing" "time" - "bd2server/internal/cryptox" - "bd2server/internal/wire" + "bd2server/internal/server/cryptox" + "bd2server/internal/server/wire" ) func TestEncodeUsesFreshLocalKey(t *testing.T) { user := wire.AppendVarint(nil, 1, 42) user = wire.AppendString(user, 2, "Guest_42") user = wire.AppendVarint(user, 5, 100) - seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 11, UserInfo: user} + seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: user} const local = "0123456789abcdef0123456789abcdef" body, err := seed.Encode(local, time.UnixMilli(1234)) if err != nil { @@ -48,7 +48,7 @@ func TestEncodeUsesFreshLocalKey(t *testing.T) { } func TestLoginValidatesEncryptedRequest(t *testing.T) { - seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)} + seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)} if _, err := seed.Login([]byte("not protobuf"), []byte("0123456789abcdef0123456789abcdef")); err == nil { t.Fatal("Login accepted invalid protobuf request") } @@ -57,11 +57,11 @@ func TestLoginValidatesEncryptedRequest(t *testing.T) { func TestLoadRejectsSeedWithUserKey(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "bad.json") - seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 11, UserInfo: wire.AppendString(nil, 3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")} + seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendString(nil, 3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")} if err := seed.Write(path); err == nil { t.Fatal("Write accepted user_key") } - if err := os.WriteFile(path, []byte(`{"version":"2.34.13","packet_code":11,"user_info_base64":"GgF4"}`), 0o644); err != nil { + if err := os.WriteFile(path, []byte(`{"version":"2.35.10","packet_code":11,"user_info_base64":"GgF4"}`), 0o644); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { @@ -70,7 +70,7 @@ func TestLoadRejectsSeedWithUserKey(t *testing.T) { } func TestCheckedInSeedBuildsLoginWithoutCapture(t *testing.T) { - seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "login_user.json")) + seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "login_user.json")) if err != nil { t.Fatal(err) } @@ -96,7 +96,7 @@ func (loginCurrencyFixture) Currencies() (uint64, uint64, uint64, uint64) { func (loginCurrencyFixture) EquipmentMileageBalances() (uint64, uint64) { return 17, 845 } func TestLoginRestoresEquipmentMileageFromCurrencyProvider(t *testing.T) { - seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)} + seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)} if err := seed.AttachCurrencies(loginCurrencyFixture{}); err != nil { t.Fatal(err) } @@ -126,7 +126,7 @@ func TestLoginReplacesSeedPurchaseCountsFromProvider(t *testing.T) { stale := wire.AppendVarint(nil, 1, 999) userTemplate := wire.AppendVarint(nil, 1, 1) userTemplate = wire.AppendBytes(userTemplate, 26, stale) - seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 3, UserInfo: userTemplate} + seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate} current := wire.AppendVarint(nil, 1, 1100001) current = wire.AppendVarint(current, 2, 9100033) @@ -175,7 +175,7 @@ func (f *loginPresetSlotFixture) PresetSlotCount() uint64 { return f.count } func TestLoginReplacesSeedPresetSlotFromProvider(t *testing.T) { userTemplate := wire.AppendVarint(nil, 1, 1) userTemplate = wire.AppendVarint(userTemplate, 28, 6) - seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 3, UserInfo: userTemplate} + seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate} provider := &loginPresetSlotFixture{count: 9} if err := seed.AttachPresetSlots(provider); err != nil { t.Fatal(err) @@ -223,7 +223,7 @@ func TestLoginReplacesAllInventorySlotFieldsFromProvider(t *testing.T) { for field, value := range map[int]uint64{5: 100, 6: 100, 10: 500, 15: 100} { user = wire.AppendVarint(user, field, value) } - seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 3, UserInfo: user} + seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user} provider := &loginInventorySlotFixture{items: 500, storage: 100, equipment: 2000, equipmentStorage: 100} if err := seed.AttachInventorySlots(provider); err != nil { t.Fatal(err) @@ -248,7 +248,7 @@ func TestSeedInventorySlotsReadsUserInfoFields(t *testing.T) { for field, value := range map[int]uint64{5: 100, 6: 101, 10: 500, 15: 102} { user = wire.AppendVarint(user, field, value) } - seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 3, UserInfo: user} + seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user} items, storage, equipment, equipmentStorage, err := seed.SeedInventorySlots() if err != nil || items != 100 || storage != 101 || equipment != 500 || equipmentStorage != 102 { t.Fatalf("slots=%d/%d/%d/%d err=%v", items, storage, equipment, equipmentStorage, err) diff --git a/go/internal/accountstate/atomic_entry.go b/go/internal/server/accountstate/atomic_entry.go similarity index 97% rename from go/internal/accountstate/atomic_entry.go rename to go/internal/server/accountstate/atomic_entry.go index e405585..e266f37 100644 --- a/go/internal/accountstate/atomic_entry.go +++ b/go/internal/server/accountstate/atomic_entry.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "bd2server/internal/stateio" + "bd2server/internal/server/stateio" ) var _ stateio.AtomicEntryStore = (*Repository)(nil) diff --git a/go/internal/accountstate/atomic_entry_test.go b/go/internal/server/accountstate/atomic_entry_test.go similarity index 98% rename from go/internal/accountstate/atomic_entry_test.go rename to go/internal/server/accountstate/atomic_entry_test.go index 241cdf1..cc5d610 100644 --- a/go/internal/accountstate/atomic_entry_test.go +++ b/go/internal/server/accountstate/atomic_entry_test.go @@ -5,7 +5,7 @@ import ( "context" "testing" - "bd2server/internal/stateio" + "bd2server/internal/server/stateio" ) func TestSaveWithEntriesAtomicAndEntryOnly(t *testing.T) { diff --git a/go/internal/accountstate/entry.go b/go/internal/server/accountstate/entry.go similarity index 99% rename from go/internal/accountstate/entry.go rename to go/internal/server/accountstate/entry.go index 295b984..57ed460 100644 --- a/go/internal/accountstate/entry.go +++ b/go/internal/server/accountstate/entry.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - "bd2server/internal/stateio" + "bd2server/internal/server/stateio" ) var _ stateio.EntryStore = (*Repository)(nil) diff --git a/go/internal/accountstate/entry_test.go b/go/internal/server/accountstate/entry_test.go similarity index 100% rename from go/internal/accountstate/entry_test.go rename to go/internal/server/accountstate/entry_test.go diff --git a/go/internal/accountstate/repository.go b/go/internal/server/accountstate/repository.go similarity index 99% rename from go/internal/accountstate/repository.go rename to go/internal/server/accountstate/repository.go index 86da831..259cc2b 100644 --- a/go/internal/accountstate/repository.go +++ b/go/internal/server/accountstate/repository.go @@ -13,7 +13,7 @@ import ( "strconv" "sync" - "bd2server/internal/stateio" + "bd2server/internal/server/stateio" _ "modernc.org/sqlite" ) diff --git a/go/internal/accountstate/repository_test.go b/go/internal/server/accountstate/repository_test.go similarity index 100% rename from go/internal/accountstate/repository_test.go rename to go/internal/server/accountstate/repository_test.go diff --git a/go/internal/accountstate/schema.go b/go/internal/server/accountstate/schema.go similarity index 100% rename from go/internal/accountstate/schema.go rename to go/internal/server/accountstate/schema.go diff --git a/go/internal/accountstate/schema_test.go b/go/internal/server/accountstate/schema_test.go similarity index 100% rename from go/internal/accountstate/schema_test.go rename to go/internal/server/accountstate/schema_test.go diff --git a/go/internal/accountstate/validate.go b/go/internal/server/accountstate/validate.go similarity index 100% rename from go/internal/accountstate/validate.go rename to go/internal/server/accountstate/validate.go diff --git a/go/internal/accountstate/validate_test.go b/go/internal/server/accountstate/validate_test.go similarity index 100% rename from go/internal/accountstate/validate_test.go rename to go/internal/server/accountstate/validate_test.go diff --git a/go/internal/server/auth/service.go b/go/internal/server/auth/service.go new file mode 100644 index 0000000..c17b4e3 --- /dev/null +++ b/go/internal/server/auth/service.go @@ -0,0 +1,852 @@ +package auth + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "bd2server/internal/server/authconfig" + "bd2server/internal/server/wire" +) + +type Service struct { + config authconfig.Runtime + store *Store + client *http.Client + limits requestLimiter +} + +type limitWindow struct { + started time.Time + count int +} + +type requestLimiter struct { + mu sync.Mutex + windows map[string]limitWindow + lastSweep time.Time +} + +type deviceResult struct { + Provider string `json:"provider"` + AccessToken string `json:"access_token"` + AccessExpiresIn int64 `json:"access_expires_in"` + RefreshToken string `json:"refresh_token"` + RefreshExpiresIn int64 `json:"refresh_expires_in"` +} + +func New(config authconfig.Runtime, store *Store) (*Service, error) { + if config.Mode != "oauth" || store == nil { + return nil, errors.New("auth: OAuth service requires oauth configuration and store") + } + // Store.Open has already derived its purpose-specific keys. Do not retain + // the environment master key in the long-lived HTTP service configuration. + clear(config.MasterKey) + config.MasterKey = nil + return &Service{config: config, store: store, client: &http.Client{Timeout: 15 * time.Second}, limits: requestLimiter{windows: make(map[string]limitWindow)}}, nil +} + +func (s *Service) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/device", s.createDevice) + mux.HandleFunc("GET /auth/{provider}/start", s.start) + mux.HandleFunc("GET /auth/{provider}/callback", s.callback) + mux.HandleFunc("POST /auth/device/{id}/poll", s.poll) + mux.HandleFunc("POST /auth/session/refresh", s.refresh) + mux.HandleFunc("POST /auth/session/revoke", s.revoke) + return securityHeaders(mux) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + w.Header().Set("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { + defer r.Body.Close() + data, err := io.ReadAll(io.LimitReader(r.Body, 16<<10+1)) + if err != nil || len(data) > 16<<10 { + http.Error(w, "request too large", http.StatusRequestEntityTooLarge) + return false + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return false + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func (s *Service) createDevice(w http.ResponseWriter, r *http.Request) { + clientIP := remoteIP(r.RemoteAddr) + if !s.limits.allow("create:"+clientIP, s.store.now(), time.Minute, 10) { + w.Header().Set("Retry-After", "60") + http.Error(w, "too many login attempts", http.StatusTooManyRequests) + return + } + var request struct { + Provider string `json:"provider"` + } + if !decodeJSON(w, r, &request) { + return + } + if _, ok := s.config.Providers[request.Provider]; !ok { + http.Error(w, "provider is not enabled", http.StatusBadRequest) + return + } + id, err := randomToken(18) + if err != nil { + http.Error(w, "could not create transaction", http.StatusInternalServerError) + return + } + secret, err := randomToken(32) + if err != nil { + http.Error(w, "could not create transaction", http.StatusInternalServerError) + return + } + startTicket, err := randomToken(32) + if err != nil { + http.Error(w, "could not create transaction", http.StatusInternalServerError) + return + } + now := s.store.now() + clientHash := s.store.digest("client-ip", clientIP) + tx, err := s.store.db.Begin() + if err != nil { + http.Error(w, "could not create transaction", http.StatusInternalServerError) + return + } + defer tx.Rollback() + if err := cleanupExpired(tx, now.Unix()); err != nil { + http.Error(w, "could not create transaction", http.StatusInternalServerError) + return + } + var pending int + if err := tx.QueryRow(`SELECT COUNT(*) FROM devices WHERE client_hash=? AND status IN ('created','authorizing') AND expires_at>?`, clientHash, now.Unix()).Scan(&pending); err != nil { + http.Error(w, "could not create transaction", http.StatusInternalServerError) + return + } + if pending >= 5 { + w.Header().Set("Retry-After", strconv.FormatInt(int64(s.config.DeviceTTL.Seconds()), 10)) + http.Error(w, "too many pending login transactions", http.StatusTooManyRequests) + return + } + _, err = tx.Exec(`INSERT INTO devices(id,client_hash,secret_hash,start_hash,provider,status,created_at,expires_at) VALUES(?,?,?,?,?,'created',?,?)`, id, clientHash, s.store.digest("device-secret", secret), s.store.digest("start-ticket", startTicket), request.Provider, now.Unix(), now.Add(s.config.DeviceTTL).Unix()) + if err != nil { + http.Error(w, "could not create transaction", http.StatusInternalServerError) + return + } + if err := tx.Commit(); err != nil { + http.Error(w, "could not create transaction", http.StatusInternalServerError) + return + } + start := *s.config.PublicURLParsed + start.Path = "/auth/" + request.Provider + "/start" + query := start.Query() + query.Set("transaction_id", id) + query.Set("ticket", startTicket) + start.RawQuery = query.Encode() + writeJSON(w, http.StatusCreated, map[string]any{"transaction_id": id, "device_secret": secret, "start_url": start.String(), "expires_in": int64(s.config.DeviceTTL.Seconds()), "poll_interval": 2}) +} + +func (s *Service) start(w http.ResponseWriter, r *http.Request) { + provider := r.PathValue("provider") + if _, ok := s.config.Providers[provider]; !ok { + http.Error(w, "provider is not enabled", http.StatusNotFound) + return + } + id, ticket := r.URL.Query().Get("transaction_id"), r.URL.Query().Get("ticket") + var storedHash []byte + var storedProvider, status string + var expires int64 + err := s.store.db.QueryRow(`SELECT start_hash,provider,status,expires_at FROM devices WHERE id=?`, id).Scan(&storedHash, &storedProvider, &status, &expires) + if err != nil || subtle.ConstantTimeCompare(storedHash, s.store.digest("start-ticket", ticket)) != 1 || storedProvider != provider || status != "created" { + http.Error(w, "invalid login transaction", http.StatusForbidden) + return + } + if s.store.now().Unix() >= expires { + http.Error(w, "login transaction expired", http.StatusGone) + return + } + state, err := randomToken(32) + if err != nil { + http.Error(w, "could not start authorization", http.StatusInternalServerError) + return + } + verifier, err := randomToken(32) + if err != nil { + http.Error(w, "could not start authorization", http.StatusInternalServerError) + return + } + nonce, err := randomToken(24) + if err != nil { + http.Error(w, "could not start authorization", http.StatusInternalServerError) + return + } + verifierCipher, err := s.store.seal(id, "pkce", []byte(verifier)) + if err != nil { + http.Error(w, "could not start authorization", http.StatusInternalServerError) + return + } + nonceCipher, err := s.store.seal(id, "nonce", []byte(nonce)) + if err != nil { + http.Error(w, "could not start authorization", http.StatusInternalServerError) + return + } + result, err := s.store.db.Exec(`UPDATE devices SET state_hash=?,verifier_cipher=?,nonce_cipher=?,start_hash=X'',status='authorizing' WHERE id=? AND status='created'`, s.store.digest("oauth-state", state), verifierCipher, nonceCipher, id) + count, affectedErr := rowsAffected(result) + if err != nil || affectedErr != nil || count != 1 { + http.Error(w, "could not start authorization", http.StatusConflict) + return + } + redirect := s.redirectURL(provider) + challenge := sha256.Sum256([]byte(verifier)) + values := url.Values{"client_id": {s.config.Providers[provider].ClientID}, "redirect_uri": {redirect}, "response_type": {"code"}, "scope": {providerScope(provider)}, "state": {state}, "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])}, "code_challenge_method": {"S256"}} + if provider == "google" { + values.Set("nonce", nonce) + } + http.Redirect(w, r, providerAuthorizeURL(provider)+"?"+values.Encode(), http.StatusFound) +} + +func (s *Service) callback(w http.ResponseWriter, r *http.Request) { + provider, state, code := r.PathValue("provider"), r.URL.Query().Get("state"), r.URL.Query().Get("code") + if _, ok := s.config.Providers[provider]; !ok { + http.Error(w, "provider is not enabled", http.StatusNotFound) + return + } + if state == "" { + http.Error(w, "authorization was not completed", http.StatusBadRequest) + return + } + if r.URL.Query().Get("error") != "" { + result, err := s.store.db.Exec(`UPDATE devices SET status='failed',error_code='provider_cancelled',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL + WHERE state_hash=? AND provider=? AND status='authorizing' AND expires_at>?`, s.store.digest("oauth-state", state), provider, s.store.now().Unix()) + if err != nil { + http.Error(w, "authorization state unavailable", http.StatusInternalServerError) + return + } + if count, err := rowsAffected(result); err != nil || count != 1 { + http.Error(w, "invalid or expired authorization state", http.StatusForbidden) + return + } + http.Error(w, "authorization was cancelled", http.StatusBadRequest) + return + } + if code == "" { + http.Error(w, "authorization was not completed", http.StatusBadRequest) + return + } + var id, storedProvider, status string + var verifierCipher, nonceCipher []byte + var expires int64 + err := s.store.db.QueryRow(`SELECT id,provider,status,verifier_cipher,nonce_cipher,expires_at FROM devices WHERE state_hash=?`, s.store.digest("oauth-state", state)).Scan(&id, &storedProvider, &status, &verifierCipher, &nonceCipher, &expires) + if err != nil || provider != storedProvider || status != "authorizing" || s.store.now().Unix() >= expires { + http.Error(w, "invalid or expired authorization state", http.StatusForbidden) + return + } + verifier, err := s.store.open(id, "pkce", verifierCipher) + if err != nil { + http.Error(w, "authorization state unavailable", http.StatusInternalServerError) + return + } + nonce, err := s.store.open(id, "nonce", nonceCipher) + if err != nil { + http.Error(w, "authorization state unavailable", http.StatusInternalServerError) + return + } + identity, err := s.exchangeIdentity(r.Context(), provider, code, string(verifier), string(nonce)) + clear(verifier) + clear(nonce) + if err != nil { + _, _ = s.store.db.Exec(`UPDATE devices SET status='failed',error_code='provider_rejected' WHERE id=? AND status='authorizing'`, id) + http.Error(w, "provider authorization failed", http.StatusBadGateway) + return + } + if err := s.completeDevice(id, provider, identity); err != nil { + code := http.StatusInternalServerError + if errors.Is(err, ErrNotAllowed) { + code = http.StatusForbidden + } else if errors.Is(err, ErrConsumed) { + code = http.StatusConflict + } + http.Error(w, "authorization could not be completed", code) + return + } + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, `BD2 login

Login complete. You can return to the game.

`) +} + +type providerIdentity struct{ issuer, subject string } + +func (s *Service) exchangeIdentity(ctx context.Context, provider, code, verifier, nonce string) (providerIdentity, error) { + values := url.Values{"client_id": {s.config.Providers[provider].ClientID}, "client_secret": {s.config.ProviderSecrets[provider]}, "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {s.redirectURL(provider)}, "code_verifier": {verifier}} + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, providerTokenURL(provider), strings.NewReader(values.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := s.client.Do(request) + if err != nil { + return providerIdentity{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return providerIdentity{}, errors.New("token exchange rejected") + } + var token struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + } + if err := decodeProviderJSON(response.Body, &token); err != nil || token.AccessToken == "" { + return providerIdentity{}, errors.New("invalid token response") + } + if provider == "google" { + if token.IDToken == "" { + return providerIdentity{}, errors.New("Google ID token missing") + } + identity, err := s.verifyGoogleIDToken(ctx, token.IDToken, nonce) + token.AccessToken, token.IDToken = "", "" + return identity, err + } + userinfo, _ := http.NewRequestWithContext(ctx, http.MethodGet, providerUserURL(provider), nil) + userinfo.Header.Set("Authorization", "Bearer "+token.AccessToken) + response, err = s.client.Do(userinfo) + token.AccessToken = "" + if err != nil { + return providerIdentity{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return providerIdentity{}, errors.New("userinfo rejected") + } + var user struct { + ID string `json:"id"` + Sub string `json:"sub"` + } + if err := decodeProviderJSON(response.Body, &user); err != nil { + return providerIdentity{}, err + } + if provider == "discord" && user.ID != "" { + return providerIdentity{issuer: "https://discord.com", subject: user.ID}, nil + } + return providerIdentity{}, errors.New("provider subject missing") +} + +// verifyGoogleIDToken delegates signature and standard-claim verification to +// Google's HTTPS tokeninfo endpoint, then independently verifies this server's +// audience, nonce and expiry. The raw ID token is never persisted or logged. +func (s *Service) verifyGoogleIDToken(ctx context.Context, idToken, nonce string) (providerIdentity, error) { + endpoint := "https://oauth2.googleapis.com/tokeninfo?id_token=" + url.QueryEscape(idToken) + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + response, err := s.client.Do(request) + if err != nil { + return providerIdentity{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return providerIdentity{}, errors.New("Google ID token rejected") + } + var claims struct { + Issuer string `json:"iss"` + Audience string `json:"aud"` + Subject string `json:"sub"` + Nonce string `json:"nonce"` + Expires string `json:"exp"` + } + if err := decodeProviderJSON(response.Body, &claims); err != nil { + return providerIdentity{}, err + } + expires, err := strconv.ParseInt(claims.Expires, 10, 64) + validIssuer := claims.Issuer == "https://accounts.google.com" || claims.Issuer == "accounts.google.com" + if err != nil || !validIssuer || claims.Audience != s.config.Providers["google"].ClientID || claims.Subject == "" || claims.Nonce != nonce || s.store.now().Unix() >= expires { + return providerIdentity{}, errors.New("Google ID token claims rejected") + } + return providerIdentity{issuer: "https://accounts.google.com", subject: claims.Subject}, nil +} + +func decodeProviderJSON(reader io.Reader, target any) error { + data, err := io.ReadAll(io.LimitReader(reader, 1<<20+1)) + if err != nil { + return err + } + if len(data) > 1<<20 { + return errors.New("auth: provider response is too large") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(target); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("auth: provider response has trailing JSON") + } + return nil +} + +func (s *Service) completeDevice(deviceID, provider string, identity providerIdentity) error { + if err := validateProviderIdentity(provider, identity); err != nil { + return err + } + now := s.store.now() + tx, err := s.store.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var accountID, status string + subjectHash := s.store.identityDigest(identity.issuer, identity.subject) + err = tx.QueryRow(`SELECT i.account_id,a.status FROM identities i JOIN accounts a ON a.id=i.account_id WHERE i.issuer=? AND i.subject_hash=?`, identity.issuer, subjectHash).Scan(&accountID, &status) + if errors.Is(err, sql.ErrNoRows) { + var count int + if err := tx.QueryRow(`SELECT COUNT(*) FROM accounts`).Scan(&count); err != nil { + return err + } + if count != 0 { + result, err := tx.Exec(`UPDATE devices SET status='failed',error_code='not_allowed',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND provider=? AND status='authorizing'`, deviceID, provider) + if err != nil { + return err + } + if count, err := rowsAffected(result); err != nil { + return err + } else if count != 1 { + return ErrConsumed + } + if err := tx.Commit(); err != nil { + return err + } + return ErrNotAllowed + } + accountID, err = randomToken(18) + if err != nil { + return err + } + if _, err = tx.Exec(`INSERT INTO accounts(id,status,created_at,last_login_at) VALUES(?,'active',?,?)`, accountID, now.Unix(), now.Unix()); err != nil { + return err + } + if _, err = tx.Exec(`INSERT INTO identities(provider,issuer,subject_hash,account_id,created_at,last_login_at) VALUES(?,?,?,?,?,?)`, provider, identity.issuer, subjectHash, accountID, now.Unix(), now.Unix()); err != nil { + return err + } + status = "active" + } else if err != nil { + return err + } else { + if _, err = tx.Exec(`UPDATE identities SET last_login_at=? WHERE issuer=? AND subject_hash=?`, now.Unix(), identity.issuer, subjectHash); err != nil { + return err + } + } + if status != "active" { + result, err := tx.Exec(`UPDATE devices SET status='failed',error_code='not_allowed',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND provider=? AND status='authorizing'`, deviceID, provider) + if err != nil { + return err + } + if count, err := rowsAffected(result); err != nil { + return err + } else if count != 1 { + return ErrConsumed + } + if err := tx.Commit(); err != nil { + return err + } + return ErrNotAllowed + } + result, familyID, err := s.issueTokens(tx, accountID, provider, now) + if err != nil { + return err + } + _ = familyID + payload, err := json.Marshal(result) + if err != nil { + return err + } + sealed, err := s.store.seal(deviceID, "result", payload) + clear(payload) + if err != nil { + return err + } + update, err := tx.Exec(`UPDATE devices SET result_cipher=?,status='complete',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND provider=? AND status='authorizing'`, sealed, deviceID, provider) + if err != nil { + return err + } + if count, err := rowsAffected(update); err != nil { + return err + } else if count != 1 { + return ErrConsumed + } + return tx.Commit() +} + +func validateProviderIdentity(provider string, identity providerIdentity) error { + switch provider { + case "discord": + if identity.issuer != "https://discord.com" || len(identity.subject) == 0 || len(identity.subject) > 32 { + return errors.New("auth: invalid Discord identity") + } + for _, digit := range identity.subject { + if digit < '0' || digit > '9' { + return errors.New("auth: invalid Discord identity") + } + } + case "google": + if identity.issuer != "https://accounts.google.com" || len(identity.subject) == 0 || len(identity.subject) > 255 { + return errors.New("auth: invalid Google identity") + } + default: + return errors.New("auth: unsupported identity provider") + } + return nil +} + +func (s *Service) issueTokens(tx *sql.Tx, accountID, provider string, now time.Time) (deviceResult, string, error) { + familyID, err := randomToken(18) + if err != nil { + return deviceResult{}, "", err + } + access, err := randomToken(32) + if err != nil { + return deviceResult{}, "", err + } + refresh, err := randomToken(32) + if err != nil { + return deviceResult{}, "", err + } + if _, err := tx.Exec(`INSERT INTO families(id,account_id,provider,created_at,expires_at) VALUES(?,?,?,?,?)`, familyID, accountID, provider, now.Unix(), now.Add(s.config.RefreshTTL).Unix()); err != nil { + return deviceResult{}, "", err + } + if _, err := tx.Exec(`INSERT INTO access_tokens(token_hash,family_id,account_id,created_at,expires_at) VALUES(?,?,?,?,?)`, s.store.digest("access-token", access), familyID, accountID, now.Unix(), now.Add(s.config.AccessTTL).Unix()); err != nil { + return deviceResult{}, "", err + } + if _, err := tx.Exec(`INSERT INTO refresh_tokens(token_hash,family_id,created_at,expires_at) VALUES(?,?,?,?)`, s.store.digest("refresh-token", refresh), familyID, now.Unix(), now.Add(s.config.RefreshTTL).Unix()); err != nil { + return deviceResult{}, "", err + } + return deviceResult{Provider: provider, AccessToken: access, AccessExpiresIn: int64(s.config.AccessTTL.Seconds()), RefreshToken: refresh, RefreshExpiresIn: int64(s.config.RefreshTTL.Seconds())}, familyID, nil +} + +func (s *Service) poll(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !s.limits.allow("poll:"+remoteIP(r.RemoteAddr)+":"+id, s.store.now(), time.Minute, 60) { + w.Header().Set("Retry-After", "2") + http.Error(w, "poll rate exceeded", http.StatusTooManyRequests) + return + } + authorization := r.Header.Get("Authorization") + if !strings.HasPrefix(authorization, "Device ") { + http.Error(w, "invalid device transaction", http.StatusForbidden) + return + } + secret := strings.TrimPrefix(authorization, "Device ") + tx, err := s.store.db.Begin() + if err != nil { + http.Error(w, "login result unavailable", http.StatusInternalServerError) + return + } + defer tx.Rollback() + var storedHash, sealed []byte + var status, errorCode string + var expires int64 + err = tx.QueryRow(`SELECT secret_hash,status,COALESCE(result_cipher,X''),COALESCE(error_code,''),expires_at FROM devices WHERE id=?`, id).Scan(&storedHash, &status, &sealed, &errorCode, &expires) + if err != nil || subtle.ConstantTimeCompare(storedHash, s.store.digest("device-secret", secret)) != 1 { + http.Error(w, "invalid device transaction", http.StatusForbidden) + return + } + if s.store.now().Unix() >= expires { + http.Error(w, "device transaction expired", http.StatusGone) + return + } + switch status { + case "created", "authorizing": + _ = tx.Rollback() + writeJSON(w, http.StatusAccepted, map[string]any{"status": "pending", "retry_after": 2}) + case "failed": + _ = tx.Rollback() + writeJSON(w, http.StatusForbidden, map[string]string{"status": "failed", "error": errorCode}) + case "complete": + plain, err := s.store.open(id, "result", sealed) + if err != nil { + http.Error(w, "login result unavailable", http.StatusInternalServerError) + return + } + result, err := tx.Exec(`UPDATE devices SET result_cipher=NULL,status='consumed' WHERE id=? AND status='complete'`, id) + count, affectedErr := rowsAffected(result) + if err != nil || affectedErr != nil || count != 1 { + _ = tx.Rollback() + clear(plain) + http.Error(w, "login result already consumed", http.StatusGone) + return + } + if err := tx.Commit(); err != nil { + clear(plain) + http.Error(w, "login result unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write(plain) + clear(plain) + default: + _ = tx.Rollback() + http.Error(w, "device transaction consumed", http.StatusGone) + } +} + +func (l *requestLimiter) allow(key string, now time.Time, duration time.Duration, maximum int) bool { + l.mu.Lock() + defer l.mu.Unlock() + if l.windows == nil { + l.windows = make(map[string]limitWindow) + } + if l.lastSweep.IsZero() || now.Sub(l.lastSweep) >= time.Minute { + for candidate, window := range l.windows { + if now.Sub(window.started) >= duration { + delete(l.windows, candidate) + } + } + l.lastSweep = now + } + window, exists := l.windows[key] + if !exists || now.Sub(window.started) >= duration { + if !exists && len(l.windows) >= 4096 { + return false + } + l.windows[key] = limitWindow{started: now, count: 1} + return true + } + if window.count >= maximum { + return false + } + window.count++ + l.windows[key] = window + return true +} + +func remoteIP(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err == nil && host != "" { + return host + } + return remoteAddr +} + +func cleanupExpired(tx *sql.Tx, now int64) error { + statements := []struct { + query string + args []any + }{ + {`DELETE FROM devices WHERE expires_at<=?`, []any{now}}, + {`DELETE FROM access_tokens WHERE expires_at<=? OR family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now, now}}, + // Used refresh rows remain until their family expires so their reuse can + // still revoke every credential in that family. + {`DELETE FROM refresh_tokens WHERE family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now}}, + {`DELETE FROM families WHERE expires_at<=?`, []any{now}}, + } + for _, statement := range statements { + if _, err := tx.Exec(statement.query, statement.args...); err != nil { + return err + } + } + return nil +} + +func (s *Service) refresh(w http.ResponseWriter, r *http.Request) { + var request struct { + RefreshToken string `json:"refresh_token"` + } + if !decodeJSON(w, r, &request) || request.RefreshToken == "" { + return + } + now := s.store.now() + tx, err := s.store.db.Begin() + if err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + defer tx.Rollback() + var familyID, accountID, provider, accountStatus string + var tokenExpires, familyExpires int64 + var usedAt, revokedAt sql.NullInt64 + err = tx.QueryRow(`SELECT r.family_id,f.account_id,f.provider,a.status,r.expires_at,f.expires_at,r.used_at,COALESCE(r.revoked_at,f.revoked_at) FROM refresh_tokens r JOIN families f ON f.id=r.family_id JOIN accounts a ON a.id=f.account_id WHERE r.token_hash=?`, s.store.digest("refresh-token", request.RefreshToken)).Scan(&familyID, &accountID, &provider, &accountStatus, &tokenExpires, &familyExpires, &usedAt, &revokedAt) + if err != nil || revokedAt.Valid || accountStatus != "active" || now.Unix() >= tokenExpires || now.Unix() >= familyExpires { + http.Error(w, "refresh token invalid", http.StatusUnauthorized) + return + } + if usedAt.Valid { + if _, err := tx.Exec(`UPDATE families SET revoked_at=? WHERE id=? AND revoked_at IS NULL`, now.Unix(), familyID); err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + if err := tx.Commit(); err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + http.Error(w, "refresh token replayed", http.StatusUnauthorized) + return + } + newAccess, err := randomToken(32) + if err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + newRefresh, err := randomToken(32) + if err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + updated, err := tx.Exec(`UPDATE refresh_tokens SET used_at=? WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL`, now.Unix(), s.store.digest("refresh-token", request.RefreshToken)) + if err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + if count, err := rowsAffected(updated); err != nil || count != 1 { + http.Error(w, "refresh token invalid", http.StatusUnauthorized) + return + } + if _, err = tx.Exec(`DELETE FROM access_tokens WHERE family_id=?`, familyID); err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + if _, err = tx.Exec(`INSERT INTO access_tokens(token_hash,family_id,account_id,created_at,expires_at) VALUES(?,?,?,?,?)`, s.store.digest("access-token", newAccess), familyID, accountID, now.Unix(), now.Add(s.config.AccessTTL).Unix()); err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + refreshExpiry := min(familyExpires, now.Add(s.config.RefreshTTL).Unix()) + if _, err = tx.Exec(`INSERT INTO refresh_tokens(token_hash,family_id,created_at,expires_at) VALUES(?,?,?,?)`, s.store.digest("refresh-token", newRefresh), familyID, now.Unix(), refreshExpiry); err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + if err = tx.Commit(); err != nil { + http.Error(w, "refresh unavailable", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, deviceResult{Provider: provider, AccessToken: newAccess, AccessExpiresIn: int64(s.config.AccessTTL.Seconds()), RefreshToken: newRefresh, RefreshExpiresIn: refreshExpiry - now.Unix()}) +} + +func (s *Service) revoke(w http.ResponseWriter, r *http.Request) { + authorization := r.Header.Get("Authorization") + if !strings.HasPrefix(authorization, "Bearer ") { + http.Error(w, "access token required", http.StatusUnauthorized) + return + } + token := strings.TrimPrefix(authorization, "Bearer ") + if token == "" { + http.Error(w, "access token required", http.StatusUnauthorized) + return + } + now := s.store.now().Unix() + tx, err := s.store.db.Begin() + if err != nil { + http.Error(w, "revocation unavailable", http.StatusInternalServerError) + return + } + defer tx.Rollback() + var familyID, accountStatus string + var expires int64 + var revoked sql.NullInt64 + err = tx.QueryRow(`SELECT t.family_id,a.status,t.expires_at,COALESCE(t.revoked_at,f.revoked_at) + FROM access_tokens t JOIN families f ON f.id=t.family_id JOIN accounts a ON a.id=t.account_id + WHERE t.token_hash=?`, s.store.digest("access-token", token)).Scan(&familyID, &accountStatus, &expires, &revoked) + if err != nil || accountStatus != "active" || revoked.Valid || now >= expires { + http.Error(w, "access token invalid", http.StatusUnauthorized) + return + } + result, err := tx.Exec(`UPDATE families SET revoked_at=? WHERE id=? AND revoked_at IS NULL`, now, familyID) + if err != nil { + http.Error(w, "revocation unavailable", http.StatusInternalServerError) + return + } + if count, err := rowsAffected(result); err != nil || count != 1 { + http.Error(w, "access token invalid", http.StatusUnauthorized) + return + } + if err := tx.Commit(); err != nil { + http.Error(w, "revocation unavailable", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Service) ValidateAccess(token string) (string, error) { + if token == "" { + return "", ErrUnauthorized + } + var accountID, status string + var expires int64 + var revoked sql.NullInt64 + err := s.store.db.QueryRow(`SELECT t.account_id,a.status,t.expires_at,COALESCE(t.revoked_at,f.revoked_at) FROM access_tokens t JOIN families f ON f.id=t.family_id JOIN accounts a ON a.id=t.account_id WHERE t.token_hash=?`, s.store.digest("access-token", token)).Scan(&accountID, &status, &expires, &revoked) + if err != nil || status != "active" || revoked.Valid || s.store.now().Unix() >= expires { + return "", ErrUnauthorized + } + return accountID, nil +} + +// AuthenticateLogin validates LoginUserRequest.access_token (field 2) before +// the game session is established. +func (s *Service) AuthenticateLogin(request []byte) (string, error) { + token, found, err := wire.Bytes(request, 2) + if err != nil || !found { + return "", ErrUnauthorized + } + return s.ValidateAccess(string(token)) +} + +func (s *Service) redirectURL(provider string) string { + return s.config.PublicURL + "/auth/" + provider + "/callback" +} +func providerScope(provider string) string { + if provider == "discord" { + return "identify" + } + return "openid" +} +func providerAuthorizeURL(provider string) string { + if provider == "discord" { + return "https://discord.com/oauth2/authorize" + } + return "https://accounts.google.com/o/oauth2/v2/auth" +} +func providerTokenURL(provider string) string { + if provider == "discord" { + return "https://discord.com/api/v10/oauth2/token" + } + return "https://oauth2.googleapis.com/token" +} +func providerUserURL(provider string) string { + return "https://discord.com/api/v10/users/@me" +} +func rowsAffected(result sql.Result) (int64, error) { + if result == nil { + return 0, errors.New("auth: missing SQL result") + } + value, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("auth: count affected rows: %w", err) + } + return value, nil +} diff --git a/go/internal/server/auth/service_test.go b/go/internal/server/auth/service_test.go new file mode 100644 index 0000000..2d7b124 --- /dev/null +++ b/go/internal/server/auth/service_test.go @@ -0,0 +1,504 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "bd2server/internal/server/authconfig" +) + +const testNowUnix = int64(1_800_000_000) + +func testService(t *testing.T) (*Service, *Store) { + t.Helper() + master := bytes.Repeat([]byte{0x42}, 32) + store, err := Open(filepath.Join(t.TempDir(), "auth.db"), master) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + store.now = func() time.Time { return time.Unix(testNowUnix, 0) } + public, _ := url.Parse("https://login.example.test") + runtime := authconfig.Runtime{ + Config: authconfig.Config{ + Mode: "oauth", + PublicURL: "https://login.example.test", + Providers: map[string]authconfig.ProviderConfig{ + "discord": {ClientID: "discord-client", ClientSecretEnv: "DISCORD_SECRET"}, + "google": {ClientID: "google-client", ClientSecretEnv: "GOOGLE_SECRET"}, + }, + }, + PublicURLParsed: public, + MasterKey: master, + ProviderSecrets: map[string]string{"discord": "discord-secret", "google": "google-secret"}, + AccessTTL: 15 * time.Minute, + RefreshTTL: 30 * 24 * time.Hour, + DeviceTTL: 10 * time.Minute, + } + service, err := New(runtime, store) + if err != nil { + t.Fatal(err) + } + if service.config.MasterKey != nil { + t.Fatal("service retained the authentication master key") + } + for i, value := range master { + if value != 0 { + t.Fatalf("master key byte %d was not cleared", i) + } + } + return service, store +} + +func insertAuthorizingDevice(t *testing.T, store *Store, id, provider string) { + t.Helper() + _, err := store.db.Exec(`INSERT INTO devices(id,client_hash,secret_hash,start_hash,provider,status,created_at,expires_at) + VALUES(?,?,?,?,?,'authorizing',?,?)`, id, store.digest("client-ip", "192.0.2.1"), store.digest("device-secret", "device-secret"), store.digest("start-ticket", "start-ticket"), provider, testNowUnix, testNowUnix+600) + if err != nil { + t.Fatal(err) + } +} + +func completeAndPoll(t *testing.T, service *Service, store *Store, deviceID, provider, issuer, subject string) deviceResult { + t.Helper() + insertAuthorizingDevice(t, store, deviceID, provider) + if err := service.completeDevice(deviceID, provider, providerIdentity{issuer: issuer, subject: subject}); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/auth/device/"+deviceID+"/poll", nil) + request.Header.Set("Authorization", "Device device-secret") + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("poll status=%d body=%q", response.Code, response.Body.String()) + } + var result deviceResult + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result +} + +func postJSON(handler http.Handler, path string, value any) *httptest.ResponseRecorder { + body, _ := json.Marshal(value) + request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func TestCompleteDeviceRequiresAuthorizingTransition(t *testing.T) { + service, store := testService(t) + insertAuthorizingDevice(t, store, "already-consumed", "discord") + if _, err := store.db.Exec(`UPDATE devices SET status='consumed' WHERE id='already-consumed'`); err != nil { + t.Fatal(err) + } + err := service.completeDevice("already-consumed", "discord", providerIdentity{issuer: "https://discord.com", subject: "123456789"}) + if !errors.Is(err, ErrConsumed) { + t.Fatalf("completeDevice error=%v, want ErrConsumed", err) + } + for _, table := range []string{"accounts", "identities", "families", "access_tokens", "refresh_tokens"} { + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("%s has %d rows after rejected completion", table, count) + } + } +} + +func TestPollConsumesEncryptedResultExactlyOnce(t *testing.T) { + service, store := testService(t) + result := completeAndPoll(t, service, store, "poll-once", "discord", "https://discord.com", "123456789") + if result.AccessToken == "" || result.RefreshToken == "" { + t.Fatal("poll omitted issued tokens") + } + request := httptest.NewRequest(http.MethodPost, "/auth/device/poll-once/poll", nil) + request.Header.Set("Authorization", "Device device-secret") + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, request) + if response.Code != http.StatusGone { + t.Fatalf("second poll status=%d body=%q", response.Code, response.Body.String()) + } + var status string + var cipher []byte + if err := store.db.QueryRow(`SELECT status,COALESCE(result_cipher,X'') FROM devices WHERE id='poll-once'`).Scan(&status, &cipher); err != nil { + t.Fatal(err) + } + if status != "consumed" || len(cipher) != 0 { + t.Fatalf("device status=%q result bytes=%d", status, len(cipher)) + } +} + +func TestRefreshRotationReplayRevokesFamily(t *testing.T) { + service, store := testService(t) + first := completeAndPoll(t, service, store, "refresh-device", "discord", "https://discord.com", "123456789") + handler := service.Handler() + response := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken}) + if response.Code != http.StatusOK { + t.Fatalf("refresh status=%d body=%q", response.Code, response.Body.String()) + } + var rotated deviceResult + if err := json.Unmarshal(response.Body.Bytes(), &rotated); err != nil { + t.Fatal(err) + } + if rotated.RefreshToken == "" || rotated.RefreshToken == first.RefreshToken || rotated.AccessToken == first.AccessToken { + t.Fatal("refresh did not rotate both credentials") + } + if _, err := service.ValidateAccess(first.AccessToken); err == nil { + t.Fatal("old access token survived refresh rotation") + } + if _, err := service.ValidateAccess(rotated.AccessToken); err != nil { + t.Fatalf("new access token rejected: %v", err) + } + + replay := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken}) + if replay.Code != http.StatusUnauthorized { + t.Fatalf("replay status=%d body=%q", replay.Code, replay.Body.String()) + } + if _, err := service.ValidateAccess(rotated.AccessToken); err == nil { + t.Fatal("refresh replay did not revoke the token family") + } + next := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": rotated.RefreshToken}) + if next.Code != http.StatusUnauthorized { + t.Fatalf("family refresh after replay status=%d", next.Code) + } +} + +func TestRevokeInvalidatesAccessAndRefreshFamily(t *testing.T) { + service, store := testService(t) + tokens := completeAndPoll(t, service, store, "revoke-device", "discord", "https://discord.com", "123456789") + request := httptest.NewRequest(http.MethodPost, "/auth/session/revoke", nil) + request.Header.Set("Authorization", "Bearer "+tokens.AccessToken) + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("revoke status=%d body=%q", response.Code, response.Body.String()) + } + if _, err := service.ValidateAccess(tokens.AccessToken); err == nil { + t.Fatal("revoked access token remained valid") + } + refresh := postJSON(service.Handler(), "/auth/session/refresh", map[string]string{"refresh_token": tokens.RefreshToken}) + if refresh.Code != http.StatusUnauthorized { + t.Fatalf("revoked refresh status=%d", refresh.Code) + } +} + +func TestSensitiveAuthenticationMaterialIsNotStoredInPlaintext(t *testing.T) { + service, store := testService(t) + handler := service.Handler() + created := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"}) + if created.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%q", created.Code, created.Body.String()) + } + var device struct { + ID string `json:"transaction_id"` + Secret string `json:"device_secret"` + StartURL string `json:"start_url"` + } + if err := json.Unmarshal(created.Body.Bytes(), &device); err != nil { + t.Fatal(err) + } + startURL, _ := url.Parse(device.StartURL) + ticket := startURL.Query().Get("ticket") + start := httptest.NewRequest(http.MethodGet, startURL.RequestURI(), nil) + started := httptest.NewRecorder() + handler.ServeHTTP(started, start) + if started.Code != http.StatusFound { + t.Fatalf("start status=%d body=%q", started.Code, started.Body.String()) + } + authorize, _ := url.Parse(started.Header().Get("Location")) + state := authorize.Query().Get("state") + var secretHash, startHash, stateHash, verifierCipher, nonceCipher []byte + if err := store.db.QueryRow(`SELECT secret_hash,start_hash,state_hash,verifier_cipher,nonce_cipher FROM devices WHERE id=?`, device.ID). + Scan(&secretHash, &startHash, &stateHash, &verifierCipher, &nonceCipher); err != nil { + t.Fatal(err) + } + verifier, err := store.open(device.ID, "pkce", verifierCipher) + if err != nil { + t.Fatal(err) + } + nonce, err := store.open(device.ID, "nonce", nonceCipher) + if err != nil { + t.Fatal(err) + } + for name, pair := range map[string]struct{ stored, raw []byte }{ + "device secret": {secretHash, []byte(device.Secret)}, + "start ticket": {startHash, []byte(ticket)}, + "oauth state": {stateHash, []byte(state)}, + "pkce verifier": {verifierCipher, verifier}, + "oidc nonce": {nonceCipher, nonce}, + } { + if bytes.Equal(pair.stored, pair.raw) || bytes.Contains(pair.stored, pair.raw) { + t.Fatalf("%s was stored in plaintext", name) + } + } + + const providerSubject = "987654321012345678" + if err := service.completeDevice(device.ID, "discord", providerIdentity{issuer: "https://discord.com", subject: providerSubject}); err != nil { + t.Fatal(err) + } + var subjectHash, sealedResult []byte + if err := store.db.QueryRow(`SELECT subject_hash FROM identities`).Scan(&subjectHash); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRow(`SELECT result_cipher FROM devices WHERE id=?`, device.ID).Scan(&sealedResult); err != nil { + t.Fatal(err) + } + plainResult, err := store.open(device.ID, "result", sealedResult) + if err != nil { + t.Fatal(err) + } + var issued deviceResult + if err := json.Unmarshal(plainResult, &issued); err != nil { + t.Fatal(err) + } + if bytes.Contains(subjectHash, []byte(providerSubject)) { + t.Fatal("provider subject was stored in plaintext") + } + for name, raw := range map[string]string{"access token": issued.AccessToken, "refresh token": issued.RefreshToken} { + if bytes.Contains(sealedResult, []byte(raw)) { + t.Fatalf("pending %s was stored outside AES-GCM ciphertext", name) + } + var count int + table := "access_tokens" + if name == "refresh token" { + table = "refresh_tokens" + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE token_hash=?`, []byte(raw)).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("%s was stored in plaintext", name) + } + } +} + +func TestJSONLimitsAndSecurityHeaders(t *testing.T) { + service, _ := testService(t) + handler := service.Handler() + for name, body := range map[string]struct { + body string + want int + }{ + "trailing": {`{"provider":"discord"}{}`, http.StatusBadRequest}, + "oversize": {`{"provider":"discord","padding":"` + strings.Repeat("x", 17<<10) + `"}`, http.StatusRequestEntityTooLarge}, + } { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/auth/device", strings.NewReader(body.body)) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != body.want { + t.Fatalf("status=%d body=%q", response.Code, response.Body.String()) + } + for header, want := range map[string]string{ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + } { + if got := response.Header().Get(header); got != want { + t.Fatalf("%s=%q want %q", header, got, want) + } + } + }) + } +} + +func TestCreateDeviceLimitsPendingTransactionsPerClient(t *testing.T) { + service, store := testService(t) + handler := service.Handler() + for i := 0; i < 5; i++ { + response := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"}) + if response.Code != http.StatusCreated { + t.Fatalf("create %d status=%d body=%q", i, response.Code, response.Body.String()) + } + } + response := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"}) + if response.Code != http.StatusTooManyRequests { + t.Fatalf("pending limit status=%d body=%q", response.Code, response.Body.String()) + } + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM devices`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 5 { + t.Fatalf("device count=%d want 5", count) + } +} + +func TestDecodeProviderJSONRejectsOversizeAndTrailingValues(t *testing.T) { + var target map[string]any + if err := decodeProviderJSON(strings.NewReader(`{"id":"1"}{}`), &target); err == nil { + t.Fatal("accepted provider response with trailing JSON") + } + oversize := `{"padding":"` + strings.Repeat("x", 1<<20) + `"}` + if err := decodeProviderJSON(strings.NewReader(oversize), &target); err == nil { + t.Fatal("accepted oversized provider response") + } +} + +func TestRequestLimiterIsBoundedAndExpiresWindows(t *testing.T) { + limiter := requestLimiter{windows: make(map[string]limitWindow)} + now := time.Unix(testNowUnix, 0) + for i := 0; i < 4096; i++ { + if !limiter.allow(strconv.Itoa(i), now, time.Minute, 1) { + t.Fatalf("rejected window %d before capacity", i) + } + } + if limiter.allow("overflow", now, time.Minute, 1) { + t.Fatal("accepted a limiter key beyond its bounded capacity") + } + if !limiter.allow("after-expiry", now.Add(time.Minute), time.Minute, 1) { + t.Fatal("did not clean expired limiter windows") + } +} + +func TestCleanupRetainsUsedRefreshForReplayUntilFamilyExpiry(t *testing.T) { + service, store := testService(t) + tokens := completeAndPoll(t, service, store, "cleanup-device", "discord", "https://discord.com", "123456789") + if _, err := store.db.Exec(`UPDATE refresh_tokens SET used_at=? WHERE token_hash=?`, testNowUnix, store.digest("refresh-token", tokens.RefreshToken)); err != nil { + t.Fatal(err) + } + tx, err := store.db.Begin() + if err != nil { + t.Fatal(err) + } + if err := cleanupExpired(tx, testNowUnix+int64((29*24*time.Hour).Seconds())); err != nil { + _ = tx.Rollback() + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE used_at IS NOT NULL`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatal("used refresh token was removed before family expiry") + } + tx, err = store.db.Begin() + if err != nil { + t.Fatal(err) + } + if err := cleanupExpired(tx, testNowUnix+int64((31*24*time.Hour).Seconds())); err != nil { + _ = tx.Rollback() + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("expired family refresh token was not cleaned") + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func jsonResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)} +} + +func TestProviderIdentityVerification(t *testing.T) { + service, _ := testService(t) + service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Host + request.URL.Path { + case "discord.com/api/v10/oauth2/token": + return jsonResponse(http.StatusOK, `{"access_token":"provider-access"}`), nil + case "discord.com/api/v10/users/@me": + if request.Header.Get("Authorization") != "Bearer provider-access" { + t.Fatal("Discord bearer token missing") + } + return jsonResponse(http.StatusOK, `{"id":"123456789"}`), nil + case "oauth2.googleapis.com/token": + return jsonResponse(http.StatusOK, `{"access_token":"provider-access","id_token":"signed-id-token"}`), nil + case "oauth2.googleapis.com/tokeninfo": + return jsonResponse(http.StatusOK, `{"iss":"https://accounts.google.com","aud":"google-client","sub":"google-subject","nonce":"expected-nonce","exp":"1900000000"}`), nil + default: + t.Fatalf("unexpected provider request %s", request.URL) + return nil, nil + } + })} + discord, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce") + if err != nil || discord.issuer != "https://discord.com" || discord.subject != "123456789" { + t.Fatalf("Discord identity=%+v err=%v", discord, err) + } + google, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "expected-nonce") + if err != nil || google.issuer != "https://accounts.google.com" || google.subject != "google-subject" { + t.Fatalf("Google identity=%+v err=%v", google, err) + } + if _, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "wrong-nonce"); err == nil { + t.Fatal("Google identity accepted the wrong OIDC nonce") + } +} + +func TestProviderScopesUseLeastPrivilege(t *testing.T) { + if got := providerScope("discord"); got != "identify" { + t.Fatalf("Discord scope=%q, want identify", got) + } + if got := providerScope("google"); got != "openid" { + t.Fatalf("Google scope=%q, want openid", got) + } +} + +func TestProviderErrorConsumesAuthorizationStateWithoutExchange(t *testing.T) { + service, store := testService(t) + insertAuthorizingDevice(t, store, "cancelled-device", "discord") + state := "cancelled-oauth-state" + if _, err := store.db.Exec(`UPDATE devices SET state_hash=? WHERE id='cancelled-device'`, store.digest("oauth-state", state)); err != nil { + t.Fatal(err) + } + service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + t.Fatal("provider error callback attempted a token exchange") + return nil, nil + })} + request := httptest.NewRequest(http.MethodGet, "/auth/discord/callback?error=access_denied&state="+url.QueryEscape(state), nil) + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%q", response.Code, response.Body.String()) + } + var status, errorCode string + var stateHash, verifier, nonce []byte + if err := store.db.QueryRow(`SELECT status,error_code,COALESCE(state_hash,X''),COALESCE(verifier_cipher,X''),COALESCE(nonce_cipher,X'') FROM devices WHERE id='cancelled-device'`). + Scan(&status, &errorCode, &stateHash, &verifier, &nonce); err != nil { + t.Fatal(err) + } + if status != "failed" || errorCode != "provider_cancelled" || len(stateHash) != 0 || len(verifier) != 0 || len(nonce) != 0 { + t.Fatalf("cancelled device status=%q error=%q state=%d verifier=%d nonce=%d", status, errorCode, len(stateHash), len(verifier), len(nonce)) + } +} + +func TestCompleteDeviceRejectsMalformedProviderIdentity(t *testing.T) { + service, store := testService(t) + insertAuthorizingDevice(t, store, "bad-identity", "discord") + if err := service.completeDevice("bad-identity", "discord", providerIdentity{issuer: "https://discord.com", subject: "not-a-snowflake"}); err == nil { + t.Fatal("accepted malformed Discord identity") + } + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM identities`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("malformed identity was persisted") + } +} diff --git a/go/internal/server/auth/store.go b/go/internal/server/auth/store.go new file mode 100644 index 0000000..262704f --- /dev/null +++ b/go/internal/server/auth/store.go @@ -0,0 +1,166 @@ +package auth + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +const schemaVersion = 1 + +var ( + ErrUnauthorized = errors.New("auth: unauthorized") + ErrExpired = errors.New("auth: expired") + ErrPending = errors.New("auth: pending") + ErrConsumed = errors.New("auth: consumed") + ErrNotAllowed = errors.New("auth: identity is not allowed on this single-owner server") +) + +type Store struct { + db *sql.DB + hashKey []byte + aead cipher.AEAD + now func() time.Time +} + +func Open(path string, masterKey []byte) (*Store, error) { + if len(masterKey) != 32 { + return nil, errors.New("auth: master key must contain 32 bytes") + } + defer clear(masterKey) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("auth: create database directory: %w", err) + } + db, err := sql.Open("sqlite", filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("auth: open database: %w", err) + } + db.SetMaxOpenConns(1) + opened := false + defer func() { + if !opened { + _ = db.Close() + } + }() + if _, err = db.Exec(`PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000`); err != nil { + return nil, fmt.Errorf("auth: configure database: %w", err) + } + if err = os.Chmod(filepath.Clean(path), 0o600); err != nil { + return nil, fmt.Errorf("auth: restrict database permissions: %w", err) + } + tx, err := db.BeginTx(context.Background(), nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + statements := []string{ + `CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL) WITHOUT ROWID`, + `CREATE TABLE IF NOT EXISTS accounts (id TEXT PRIMARY KEY NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL) WITHOUT ROWID`, + `CREATE TABLE IF NOT EXISTS identities (provider TEXT NOT NULL, issuer TEXT NOT NULL, subject_hash BLOB NOT NULL, account_id TEXT NOT NULL REFERENCES accounts(id), created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL, PRIMARY KEY(issuer,subject_hash)) WITHOUT ROWID`, + `CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY NOT NULL, client_hash BLOB NOT NULL, secret_hash BLOB NOT NULL, start_hash BLOB NOT NULL, provider TEXT NOT NULL, state_hash BLOB, verifier_cipher BLOB, nonce_cipher BLOB, result_cipher BLOB, status TEXT NOT NULL, error_code TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL) WITHOUT ROWID`, + `CREATE INDEX IF NOT EXISTS devices_client_pending ON devices(client_hash,status,expires_at)`, + `CREATE UNIQUE INDEX IF NOT EXISTS devices_state ON devices(state_hash) WHERE state_hash IS NOT NULL`, + `CREATE TABLE IF NOT EXISTS families (id TEXT PRIMARY KEY NOT NULL, account_id TEXT NOT NULL REFERENCES accounts(id), provider TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER) WITHOUT ROWID`, + `CREATE TABLE IF NOT EXISTS refresh_tokens (token_hash BLOB PRIMARY KEY NOT NULL, family_id TEXT NOT NULL REFERENCES families(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER, revoked_at INTEGER) WITHOUT ROWID`, + `CREATE TABLE IF NOT EXISTS access_tokens (token_hash BLOB PRIMARY KEY NOT NULL, family_id TEXT NOT NULL REFERENCES families(id), account_id TEXT NOT NULL REFERENCES accounts(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER) WITHOUT ROWID`, + } + for _, statement := range statements { + if _, err = tx.Exec(statement); err != nil { + return nil, fmt.Errorf("auth: create schema: %w", err) + } + } + var version int + err = tx.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version) + if errors.Is(err, sql.ErrNoRows) { + if _, err = tx.Exec(`INSERT INTO metadata(key,value) VALUES('schema_version',?)`, schemaVersion); err != nil { + return nil, err + } + version = schemaVersion + } else if err != nil { + return nil, err + } + if version != schemaVersion { + return nil, fmt.Errorf("auth: schema version %d, want %d", version, schemaVersion) + } + if err = tx.Commit(); err != nil { + return nil, err + } + encryptionKey := derive(masterKey, "auth-encryption") + hashKey := derive(masterKey, "auth-token-hmac") + block, err := aes.NewCipher(encryptionKey) + clear(encryptionKey) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + opened = true + return &Store{db: db, hashKey: hashKey, aead: aead, now: time.Now}, nil +} + +func (s *Store) Close() error { + clear(s.hashKey) + return s.db.Close() +} + +func derive(master []byte, purpose string) []byte { + mac := hmac.New(sha256.New, master) + _, _ = mac.Write([]byte("bd2/" + purpose + "/v1")) + return mac.Sum(nil) +} + +func (s *Store) digest(purpose, raw string) []byte { + mac := hmac.New(sha256.New, s.hashKey) + _, _ = mac.Write([]byte(purpose)) + _, _ = mac.Write([]byte{'\x00'}) + _, _ = mac.Write([]byte(raw)) + return mac.Sum(nil) +} + +func (s *Store) identityDigest(issuer, subject string) []byte { + mac := hmac.New(sha256.New, s.hashKey) + _, _ = mac.Write([]byte("identity\x00")) + _, _ = mac.Write([]byte(issuer)) + _, _ = mac.Write([]byte{'\x00'}) + _, _ = mac.Write([]byte(subject)) + return mac.Sum(nil) +} + +func randomToken(bytes int) (string, error) { + value := make([]byte, bytes) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func (s *Store) seal(id, field string, plain []byte) ([]byte, error) { + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + aad := []byte("bd2/auth/v1/" + id + "/" + field) + return s.aead.Seal(nonce, nonce, plain, aad), nil +} + +func (s *Store) open(id, field string, sealed []byte) ([]byte, error) { + if len(sealed) < s.aead.NonceSize() { + return nil, errors.New("auth: invalid ciphertext") + } + nonce, ciphertext := sealed[:s.aead.NonceSize()], sealed[s.aead.NonceSize():] + return s.aead.Open(nil, nonce, ciphertext, []byte("bd2/auth/v1/"+id+"/"+field)) +} diff --git a/go/internal/server/auth/store_test.go b/go/internal/server/auth/store_test.go new file mode 100644 index 0000000..073b446 --- /dev/null +++ b/go/internal/server/auth/store_test.go @@ -0,0 +1,48 @@ +package auth + +import ( + "bytes" + "path/filepath" + "testing" +) + +func TestStoreRequiresAndClearsExactMasterKey(t *testing.T) { + if _, err := Open(filepath.Join(t.TempDir(), "short.db"), make([]byte, 31)); err == nil { + t.Fatal("store accepted a non-256-bit master key") + } + key := bytes.Repeat([]byte{0x7a}, 32) + store, err := Open(filepath.Join(t.TempDir(), "auth.db"), key) + if err != nil { + t.Fatal(err) + } + defer store.Close() + for index, value := range key { + if value != 0 { + t.Fatalf("master key byte %d was retained by the caller buffer", index) + } + } +} + +func TestStorePersistsExplicitSchemaVersionAndRejectsUnknownVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.db") + store, err := Open(path, bytes.Repeat([]byte{0x35}, 32)) + if err != nil { + t.Fatal(err) + } + var version int + if err := store.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version); err != nil { + t.Fatal(err) + } + if version != schemaVersion { + t.Fatalf("schema_version=%d, want %d", version, schemaVersion) + } + if _, err := store.db.Exec(`UPDATE metadata SET value='999' WHERE key='schema_version'`); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + if _, err := Open(path, bytes.Repeat([]byte{0x35}, 32)); err == nil { + t.Fatal("store accepted an unknown schema version") + } +} diff --git a/go/internal/server/authconfig/config.go b/go/internal/server/authconfig/config.go new file mode 100644 index 0000000..a1fd233 --- /dev/null +++ b/go/internal/server/authconfig/config.go @@ -0,0 +1,175 @@ +// Package authconfig loads the server-authoritative authentication policy. +package authconfig + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const FileName = "authentication.json" + +// TODO: Replace the executable-adjacent file with a small authenticated +// configuration API while keeping this policy server-authoritative. +type Config struct { + Mode string `json:"mode"` + PublicURL string `json:"public_url,omitempty"` + MasterKeyEnv string `json:"master_key_env,omitempty"` + Providers map[string]ProviderConfig `json:"providers,omitempty"` + Session SessionConfig `json:"session,omitempty"` +} + +type ProviderConfig struct { + ClientID string `json:"client_id"` + ClientSecretEnv string `json:"client_secret_env"` +} + +type SessionConfig struct { + AccessTTL string `json:"access_ttl,omitempty"` + RefreshTTL string `json:"refresh_ttl,omitempty"` + DeviceTransactionTTL string `json:"device_transaction_ttl,omitempty"` +} + +type Runtime struct { + Config + PublicURLParsed *url.URL + MasterKey []byte + ProviderSecrets map[string]string + AccessTTL time.Duration + RefreshTTL time.Duration + DeviceTTL time.Duration +} + +type Public struct { + Mode string `json:"mode"` + Providers []string `json:"providers"` +} + +func Load(path string) (Config, error) { + path, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return Config{}, fmt.Errorf("authconfig: resolve path: %w", err) + } + file, err := os.Open(path) + if err != nil { + return Config{}, fmt.Errorf("authconfig: open %s: %w", path, err) + } + defer file.Close() + var config Config + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&config); err != nil { + return Config{}, fmt.Errorf("authconfig: decode %s: %w", path, err) + } + var trailing any + if err := decoder.Decode(&trailing); err == nil { + return Config{}, fmt.Errorf("authconfig: trailing JSON in %s", path) + } else if !errors.Is(err, io.EOF) { + return Config{}, fmt.Errorf("authconfig: trailing data in %s: %w", path, err) + } + if err := config.Validate(); err != nil { + return Config{}, fmt.Errorf("authconfig: %s: %w", path, err) + } + return config, nil +} + +func BesideExecutable() (string, error) { + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("authconfig: resolve executable: %w", err) + } + return filepath.Join(filepath.Dir(executable), FileName), nil +} + +func (c Config) Validate() error { + if c.Mode != "local" && c.Mode != "oauth" { + return errors.New("mode must be local or oauth") + } + if c.Mode == "local" { + if c.PublicURL != "" || c.MasterKeyEnv != "" || len(c.Providers) != 0 { + return errors.New("local mode must not configure OAuth") + } + return validateTTLs(c.Session) + } + if c.MasterKeyEnv == "" || c.PublicURL == "" || len(c.Providers) == 0 { + return errors.New("oauth mode requires public_url, master_key_env, and providers") + } + publicURL, err := url.Parse(c.PublicURL) + if err != nil || publicURL.Host == "" || publicURL.User != nil || publicURL.RawQuery != "" || publicURL.Fragment != "" || publicURL.Path != "" { + return errors.New("public_url must be an absolute origin without path, query, fragment, or user info") + } + localhost := publicURL.Hostname() == "127.0.0.1" || publicURL.Hostname() == "localhost" || publicURL.Hostname() == "::1" + if publicURL.Scheme != "https" && !(localhost && publicURL.Scheme == "http") { + return errors.New("public_url must use HTTPS except on localhost") + } + for name, provider := range c.Providers { + if name != "discord" && name != "google" { + return fmt.Errorf("unsupported provider %q", name) + } + if provider.ClientID == "" || provider.ClientSecretEnv == "" { + return fmt.Errorf("provider %q requires client_id and client_secret_env", name) + } + } + return validateTTLs(c.Session) +} + +func validateTTLs(session SessionConfig) error { + for name, raw := range map[string]string{"access_ttl": session.AccessTTL, "refresh_ttl": session.RefreshTTL, "device_transaction_ttl": session.DeviceTransactionTTL} { + if raw != "" { + if value, err := time.ParseDuration(raw); err != nil || value <= 0 { + return fmt.Errorf("%s must be a positive Go duration", name) + } + } + } + return nil +} + +func (c Config) ResolveEnvironment() (Runtime, error) { + if err := c.Validate(); err != nil { + return Runtime{}, err + } + runtime := Runtime{Config: c, ProviderSecrets: make(map[string]string), AccessTTL: 15 * time.Minute, RefreshTTL: 30 * 24 * time.Hour, DeviceTTL: 10 * time.Minute} + if c.Session.AccessTTL != "" { + runtime.AccessTTL, _ = time.ParseDuration(c.Session.AccessTTL) + } + if c.Session.RefreshTTL != "" { + runtime.RefreshTTL, _ = time.ParseDuration(c.Session.RefreshTTL) + } + if c.Session.DeviceTransactionTTL != "" { + runtime.DeviceTTL, _ = time.ParseDuration(c.Session.DeviceTransactionTTL) + } + if c.Mode == "local" { + return runtime, nil + } + runtime.PublicURLParsed, _ = url.Parse(c.PublicURL) + key, err := base64.StdEncoding.DecodeString(os.Getenv(c.MasterKeyEnv)) + if err != nil || len(key) != 32 { + return Runtime{}, fmt.Errorf("authconfig: %s must contain a base64-encoded 32-byte key", c.MasterKeyEnv) + } + runtime.MasterKey = key + for name, provider := range c.Providers { + secret := os.Getenv(provider.ClientSecretEnv) + if strings.TrimSpace(secret) == "" { + return Runtime{}, fmt.Errorf("authconfig: provider %s secret environment %s is empty", name, provider.ClientSecretEnv) + } + runtime.ProviderSecrets[name] = secret + } + return runtime, nil +} + +func (c Config) Public() Public { + view := Public{Mode: c.Mode, Providers: []string{}} + for _, name := range []string{"discord", "google"} { + if _, ok := c.Providers[name]; ok { + view.Providers = append(view.Providers, name) + } + } + return view +} diff --git a/go/internal/server/authconfig/config_test.go b/go/internal/server/authconfig/config_test.go new file mode 100644 index 0000000..3dc09fc --- /dev/null +++ b/go/internal/server/authconfig/config_test.go @@ -0,0 +1,99 @@ +package authconfig + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoad(t *testing.T) { + path := filepath.Join(t.TempDir(), FileName) + if err := os.WriteFile(path, []byte(`{"mode":"oauth","public_url":"https://example.com","master_key_env":"MASTER","providers":{"discord":{"client_id":"d","client_secret_env":"DS"},"google":{"client_id":"g","client_secret_env":"GS"}}}`), 0o600); err != nil { + t.Fatal(err) + } + config, err := Load(path) + if err != nil { + t.Fatal(err) + } + if config.Mode != "oauth" || len(config.Providers) != 2 || config.Providers["discord"].ClientID != "d" || config.Providers["google"].ClientID != "g" { + t.Fatalf("unexpected config: %+v", config) + } +} + +func TestValidateRejectsUnsafePolicies(t *testing.T) { + for name, config := range map[string]Config{ + "unknown mode": {Mode: "disabled"}, + "local providers": {Mode: "local", Providers: map[string]ProviderConfig{"discord": {ClientID: "d", ClientSecretEnv: "DS"}}}, + "empty oauth": {Mode: "oauth"}, + "unknown provider": {Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER", Providers: map[string]ProviderConfig{"github": {ClientID: "g", ClientSecretEnv: "GS"}}}, + } { + t.Run(name, func(t *testing.T) { + if err := config.Validate(); err == nil { + t.Fatal("accepted invalid authentication policy") + } + }) + } +} + +func TestResolveEnvironmentKeepsSecretsOutOfPublicView(t *testing.T) { + master := make([]byte, 32) + for i := range master { + master[i] = byte(i + 1) + } + t.Setenv("AUTH_MASTER", base64.StdEncoding.EncodeToString(master)) + t.Setenv("DISCORD_SECRET", "private-discord-secret") + config := Config{ + Mode: "oauth", + PublicURL: "https://example.com", + MasterKeyEnv: "AUTH_MASTER", + Providers: map[string]ProviderConfig{ + "discord": {ClientID: "public-client-id", ClientSecretEnv: "DISCORD_SECRET"}, + }, + } + runtime, err := config.ResolveEnvironment() + if err != nil { + t.Fatal(err) + } + if len(runtime.MasterKey) != 32 || runtime.ProviderSecrets["discord"] != "private-discord-secret" { + t.Fatal("runtime did not resolve authentication secrets") + } + publicJSON, err := json.Marshal(config.Public()) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"AUTH_MASTER", "DISCORD_SECRET", "private-discord-secret", "public-client-id"} { + if strings.Contains(string(publicJSON), forbidden) { + t.Fatalf("public authentication view leaked %q: %s", forbidden, publicJSON) + } + } +} + +func TestValidateRejectsUnsafePublicURLsAndTTLs(t *testing.T) { + base := Config{ + Mode: "oauth", + PublicURL: "https://example.com", + MasterKeyEnv: "MASTER", + Providers: map[string]ProviderConfig{ + "discord": {ClientID: "d", ClientSecretEnv: "DS"}, + }, + } + for name, mutate := range map[string]func(*Config){ + "http public": func(c *Config) { c.PublicURL = "http://example.com" }, + "path": func(c *Config) { c.PublicURL = "https://example.com/auth" }, + "query": func(c *Config) { c.PublicURL = "https://example.com?x=y" }, + "missing secret": func(c *Config) { c.Providers["discord"] = ProviderConfig{ClientID: "d"} }, + "invalid ttl": func(c *Config) { c.Session.AccessTTL = "0s" }, + } { + t.Run(name, func(t *testing.T) { + candidate := base + candidate.Providers = map[string]ProviderConfig{"discord": base.Providers["discord"]} + mutate(&candidate) + if err := candidate.Validate(); err == nil { + t.Fatal("accepted unsafe authentication configuration") + } + }) + } +} diff --git a/go/internal/battle/battle.go b/go/internal/server/battle/battle.go similarity index 98% rename from go/internal/battle/battle.go rename to go/internal/server/battle/battle.go index 377d29c..78c5d5c 100644 --- a/go/internal/battle/battle.go +++ b/go/internal/server/battle/battle.go @@ -9,9 +9,9 @@ import ( "log/slog" "sync" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" ) type Service struct { diff --git a/go/internal/battle/battle_test.go b/go/internal/server/battle/battle_test.go similarity index 97% rename from go/internal/battle/battle_test.go rename to go/internal/server/battle/battle_test.go index 9478c88..121d524 100644 --- a/go/internal/battle/battle_test.go +++ b/go/internal/server/battle/battle_test.go @@ -3,10 +3,10 @@ package battle import ( "testing" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func request(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) } @@ -125,7 +125,7 @@ func TestBattleEnterUsesSamePictorialSnapshotAsAllCharRefresh(t *testing.T) { func TestBattleVictoryLocksPackAtEnterForRewardsAndIdentity(t *testing.T) { storage := stateio.NewMemory() - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.34.13"}) + inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/bootstrap/bootstrap.go b/go/internal/server/bootstrap/bootstrap.go similarity index 91% rename from go/internal/bootstrap/bootstrap.go rename to go/internal/server/bootstrap/bootstrap.go index fc158ad..6c623e9 100644 --- a/go/internal/bootstrap/bootstrap.go +++ b/go/internal/server/bootstrap/bootstrap.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) type Config struct { @@ -23,8 +23,9 @@ type Config struct { func (c Config) Validate() error { for _, pair := range [][2]string{{"game server", c.BaseURL}, {"CDN", c.CDNURL}} { u, err := url.Parse(pair[1]) - if err != nil || u.Scheme != "http" || u.Host == "" { - return fmt.Errorf("%s needs a valid local HTTP URL: %q", pair[0], pair[1]) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || + (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("%s needs a valid HTTP(S) URL: %q", pair[0], pair[1]) } } if !strings.HasSuffix(c.BaseURL, "/") { diff --git a/go/internal/bootstrap/bootstrap_test.go b/go/internal/server/bootstrap/bootstrap_test.go similarity index 84% rename from go/internal/bootstrap/bootstrap_test.go rename to go/internal/server/bootstrap/bootstrap_test.go index ec69fa9..0d5d29f 100644 --- a/go/internal/bootstrap/bootstrap_test.go +++ b/go/internal/server/bootstrap/bootstrap_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) func TestMaintenance(t *testing.T) { @@ -49,6 +49,16 @@ func TestServerInfoNoOfficialEndpoints(t *testing.T) { } } +func TestConfigAcceptsHTTPSPublicOrigin(t *testing.T) { + cfg := Config{ + BaseURL: "https://bd2.example.com/game/", CDNURL: "https://bd2.example.com/assets/ServerData", + Version: "client", BundleVer: "bundle", + } + if err := cfg.Validate(); err != nil { + t.Fatalf("HTTPS self-hosted server config rejected: %v", err) + } +} + func TestServerInfoIncludesLocalGameData(t *testing.T) { c := Config{ BaseURL: "http://127.0.0.1:8080/game/", diff --git a/go/internal/cryptox/cryptox.go b/go/internal/server/cryptox/cryptox.go similarity index 100% rename from go/internal/cryptox/cryptox.go rename to go/internal/server/cryptox/cryptox.go diff --git a/go/internal/cryptox/cryptox_test.go b/go/internal/server/cryptox/cryptox_test.go similarity index 100% rename from go/internal/cryptox/cryptox_test.go rename to go/internal/server/cryptox/cryptox_test.go diff --git a/go/internal/dbcrypt/pages.go b/go/internal/server/dbcrypt/pages.go similarity index 89% rename from go/internal/dbcrypt/pages.go rename to go/internal/server/dbcrypt/pages.go index 58ea1fa..c41f935 100644 --- a/go/internal/dbcrypt/pages.go +++ b/go/internal/server/dbcrypt/pages.go @@ -1,5 +1,6 @@ -// Package dbcrypt implements the page cipher shared by Intro and GameData -// SQLite files in Brown Dust II 2.34.13. +// Package dbcrypt implements the page cipher used by versioned GameData +// SQLite archives. Client resource patching owns a separate copy so the pure +// server dependency graph never imports internal/client. package dbcrypt import ( diff --git a/go/internal/dbcrypt/pages_test.go b/go/internal/server/dbcrypt/pages_test.go similarity index 100% rename from go/internal/dbcrypt/pages_test.go rename to go/internal/server/dbcrypt/pages_test.go diff --git a/go/internal/deck/deck.go b/go/internal/server/deck/deck.go similarity index 95% rename from go/internal/deck/deck.go rename to go/internal/server/deck/deck.go index c6eb28a..158d4d6 100644 --- a/go/internal/deck/deck.go +++ b/go/internal/server/deck/deck.go @@ -11,10 +11,10 @@ import ( "sort" "sync" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) type DeckEntry struct { @@ -90,7 +90,7 @@ func LoadSeed(path string) (Seed, error) { return s, nil } func (s Seed) validate() error { - if s.Version != versionconfig.Protocol() { + if s.Version != versionconfig.State() { return errors.New("deck: wrong seed version") } return validField(s.FieldDeck) @@ -143,7 +143,7 @@ func NewStore(seed Seed) (*Store, error) { if e := seed.validate(); e != nil { return nil, e } - return &Store{state: state{Version: versionconfig.Protocol(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: presetBaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil + return &Store{state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: presetBaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil } func OpenStore(storage stateio.Store, seed Seed) (*Store, error) { s, e := NewStore(seed) @@ -172,7 +172,7 @@ func OpenStore(storage stateio.Store, seed Seed) (*Store, error) { if e = json.Unmarshal(b, &loaded); e != nil { return nil, fmt.Errorf("deck: malformed state: %w", e) } - if loaded.Version != versionconfig.Protocol() || (len(loaded.Deck) != 0 && validDeck(loaded.Deck) != nil) || validField(loaded.FieldDeck) != nil || loaded.Waypoints == nil || loaded.Costumes == nil || loaded.Packs == nil { + if loaded.Version != versionconfig.State() || (len(loaded.Deck) != 0 && validDeck(loaded.Deck) != nil) || validField(loaded.FieldDeck) != nil || loaded.Waypoints == nil || loaded.Costumes == nil || loaded.Packs == nil { return nil, errors.New("deck: invalid saved state") } s.state = loaded diff --git a/go/internal/deck/deck_test.go b/go/internal/server/deck/deck_test.go similarity index 96% rename from go/internal/deck/deck_test.go rename to go/internal/server/deck/deck_test.go index c11b234..d459a80 100644 --- a/go/internal/deck/deck_test.go +++ b/go/internal/server/deck/deck_test.go @@ -1,17 +1,17 @@ package deck import ( - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" "path/filepath" "testing" ) func seeded(t *testing.T) *Store { t.Helper() - x, e := LoadSeed(filepath.Join("..", "..", "seed", "v2_34_13", "decks.json")) + x, e := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json")) if e != nil { t.Fatal(e) } @@ -38,7 +38,7 @@ func triple(a, b, c uint64) []byte { func attachFormationOwnership(t *testing.T, store *Store) { t.Helper() starter := &player.Starter{ - Version: versionconfig.Protocol(), + Version: versionconfig.State(), Characters: []player.Character{ {InvenIndex: 101, ID: 350, Level: 1}, {InvenIndex: 102, ID: 351, Level: 1}, @@ -103,7 +103,7 @@ func TestFieldDeckSeedAndSave(t *testing.T) { } } func TestDeckPersistenceAndCommands(t *testing.T) { - seed, e := LoadSeed(filepath.Join("..", "..", "seed", "v2_34_13", "decks.json")) + seed, e := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json")) if e != nil { t.Fatal(e) } @@ -346,7 +346,7 @@ func TestDeckCharAutoReviveUsesCurrentFormationWithoutInventingRevives(t *testin } func TestDeckCharAutoRevivePreservesExplicitZeroCatalyst(t *testing.T) { - seed, err := LoadSeed(filepath.Join("..", "..", "seed", "v2_34_13", "decks.json")) + seed, err := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json")) if err != nil { t.Fatal(err) } diff --git a/go/internal/deck/preset.go b/go/internal/server/deck/preset.go similarity index 99% rename from go/internal/deck/preset.go rename to go/internal/server/deck/preset.go index 1eac7a0..7e68325 100644 --- a/go/internal/deck/preset.go +++ b/go/internal/server/deck/preset.go @@ -11,9 +11,9 @@ import ( "unicode" "unicode/utf8" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) const ( diff --git a/go/internal/deck/preset_test.go b/go/internal/server/deck/preset_test.go similarity index 98% rename from go/internal/deck/preset_test.go rename to go/internal/server/deck/preset_test.go index 7fd39d2..4f17ccb 100644 --- a/go/internal/deck/preset_test.go +++ b/go/internal/server/deck/preset_test.go @@ -5,9 +5,9 @@ import ( "sort" "testing" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) type presetFixture struct { @@ -24,11 +24,11 @@ type presetFixture struct { func newPresetFixture(t *testing.T) *presetFixture { t.Helper() storage := stateio.NewMemory() - seed, err := LoadSeed("../../seed/v2_34_13/decks.json") + seed, err := LoadSeed("../../../seed/v2_35_10/decks.json") if err != nil { t.Fatal(err) } - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.34.13"}) + inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/feature/bootstrap.go b/go/internal/server/feature/bootstrap.go similarity index 96% rename from go/internal/feature/bootstrap.go rename to go/internal/server/feature/bootstrap.go index 2f05ce8..f973d39 100644 --- a/go/internal/feature/bootstrap.go +++ b/go/internal/server/feature/bootstrap.go @@ -1,6 +1,6 @@ package feature -import "bd2server/internal/wire" +import "bd2server/internal/server/wire" // initialResponses are locally constructed, typed defaults for the 2.34.13 // new-player account. These do not reuse recorded response bytes. Stateful diff --git a/go/internal/feature/compatibility_test.go b/go/internal/server/feature/compatibility_test.go similarity index 89% rename from go/internal/feature/compatibility_test.go rename to go/internal/server/feature/compatibility_test.go index 524e126..9bfdd81 100644 --- a/go/internal/feature/compatibility_test.go +++ b/go/internal/server/feature/compatibility_test.go @@ -5,14 +5,14 @@ import ( "path/filepath" "testing" - "bd2server/internal/fixture" - "bd2server/internal/wire" + "bd2server/internal/server/fixture" + "bd2server/internal/server/wire" ) // TestCaptureCompatibility is an optional development-time protocol audit. // Normal server execution never opens the capture. func TestCaptureCompatibility(t *testing.T) { - root := filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254") + root := filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254") set, err := fixture.Load(root) if err != nil { t.Skipf("optional capture unavailable: %v", err) diff --git a/go/internal/feature/feature.go b/go/internal/server/feature/feature.go similarity index 99% rename from go/internal/feature/feature.go rename to go/internal/server/feature/feature.go index 1c91c56..f780541 100644 --- a/go/internal/feature/feature.go +++ b/go/internal/server/feature/feature.go @@ -9,7 +9,7 @@ import ( "errors" "fmt" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) // ErrInvalidRequest means a known endpoint was sent a malformed request. diff --git a/go/internal/feature/feature_test.go b/go/internal/server/feature/feature_test.go similarity index 98% rename from go/internal/feature/feature_test.go rename to go/internal/server/feature/feature_test.go index 531a324..1753c56 100644 --- a/go/internal/feature/feature_test.go +++ b/go/internal/server/feature/feature_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestHandleAuditedEmptyResponses(t *testing.T) { diff --git a/go/internal/feature/standalone.go b/go/internal/server/feature/standalone.go similarity index 100% rename from go/internal/feature/standalone.go rename to go/internal/server/feature/standalone.go diff --git a/go/internal/fixture/debug_response_test.go b/go/internal/server/fixture/debug_response_test.go similarity index 85% rename from go/internal/fixture/debug_response_test.go rename to go/internal/server/fixture/debug_response_test.go index f8fa0ad..eeaca9b 100644 --- a/go/internal/fixture/debug_response_test.go +++ b/go/internal/server/fixture/debug_response_test.go @@ -5,11 +5,11 @@ import ( "path/filepath" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestDebugBattleStartResponse(t *testing.T) { - set, err := Load(filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")) + set, err := Load(filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")) if err != nil { t.Skip(err) } diff --git a/go/internal/fixture/fixture.go b/go/internal/server/fixture/fixture.go similarity index 99% rename from go/internal/fixture/fixture.go rename to go/internal/server/fixture/fixture.go index 7a02c07..a8757a4 100644 --- a/go/internal/fixture/fixture.go +++ b/go/internal/server/fixture/fixture.go @@ -17,7 +17,7 @@ import ( "strconv" "strings" - "bd2server/internal/cryptox" + "bd2server/internal/server/cryptox" ) const CaptureVersion = "2.34.13" diff --git a/go/internal/fixture/fixture_test.go b/go/internal/server/fixture/fixture_test.go similarity index 98% rename from go/internal/fixture/fixture_test.go rename to go/internal/server/fixture/fixture_test.go index 05c3a40..127486c 100644 --- a/go/internal/fixture/fixture_test.go +++ b/go/internal/server/fixture/fixture_test.go @@ -8,13 +8,13 @@ import ( "path/filepath" "testing" - "bd2server/internal/cryptox" - "bd2server/internal/wire" + "bd2server/internal/server/cryptox" + "bd2server/internal/server/wire" ) func captureRoot(t *testing.T) string { t.Helper() - root, err := filepath.Abs(filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")) + root, err := filepath.Abs(filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")) if err != nil { t.Fatal(err) } diff --git a/go/internal/gacha/gacha.go b/go/internal/server/gacha/gacha.go similarity index 99% rename from go/internal/gacha/gacha.go rename to go/internal/server/gacha/gacha.go index 985ed9a..dafb85c 100644 --- a/go/internal/gacha/gacha.go +++ b/go/internal/server/gacha/gacha.go @@ -9,9 +9,9 @@ import ( "sync" "time" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" ) const infiniteGrant = "cash-product:1100001:9100033" diff --git a/go/internal/gacha/gacha_test.go b/go/internal/server/gacha/gacha_test.go similarity index 99% rename from go/internal/gacha/gacha_test.go rename to go/internal/server/gacha/gacha_test.go index 546725a..0690c05 100644 --- a/go/internal/gacha/gacha_test.go +++ b/go/internal/server/gacha/gacha_test.go @@ -5,10 +5,10 @@ import ( "testing" "time" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T) { @@ -38,7 +38,7 @@ func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T if err != nil { t.Fatal(err) } - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.34.13"}) + inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -1106,7 +1106,7 @@ func TestMoonriseSelectionCashProductAndOneTimeTicketDraw(t *testing.T) { if err != nil { t.Fatal(err) } - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.34.13"}) + inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/gacha/login_purchase_count_test.go b/go/internal/server/gacha/login_purchase_count_test.go similarity index 94% rename from go/internal/gacha/login_purchase_count_test.go rename to go/internal/server/gacha/login_purchase_count_test.go index 2c767f9..9b79d15 100644 --- a/go/internal/gacha/login_purchase_count_test.go +++ b/go/internal/server/gacha/login_purchase_count_test.go @@ -4,11 +4,11 @@ import ( "path/filepath" "testing" - "bd2server/internal/account" - "bd2server/internal/accountstate" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/wire" + "bd2server/internal/server/account" + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" ) func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) { @@ -101,7 +101,7 @@ func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) { stale := wire.AppendVarint(nil, 1, 999) userTemplate := wire.AppendVarint(nil, 1, 42) userTemplate = wire.AppendBytes(userTemplate, 26, stale) - loginSeed := &account.LoginSeed{Version: account.ProtocolVersion(), PacketCode: 11, UserInfo: userTemplate} + loginSeed := &account.LoginSeed{Version: account.StateVersion(), PacketCode: 11, UserInfo: userTemplate} if err := loginSeed.AttachPurchaseCounts(service); err != nil { t.Fatal(err) } diff --git a/go/internal/gacha/point_exchange_test.go b/go/internal/server/gacha/point_exchange_test.go similarity index 98% rename from go/internal/gacha/point_exchange_test.go rename to go/internal/server/gacha/point_exchange_test.go index a70e020..1fc30d6 100644 --- a/go/internal/gacha/point_exchange_test.go +++ b/go/internal/server/gacha/point_exchange_test.go @@ -3,10 +3,10 @@ package gacha import ( "testing" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func TestGachaPointExchangeGrantsUpgradesOverflowsAndRetries(t *testing.T) { diff --git a/go/internal/gacha/runtime_dependency_test.go b/go/internal/server/gacha/runtime_dependency_test.go similarity index 97% rename from go/internal/gacha/runtime_dependency_test.go rename to go/internal/server/gacha/runtime_dependency_test.go index 2138af0..1a0ffe2 100644 --- a/go/internal/gacha/runtime_dependency_test.go +++ b/go/internal/server/gacha/runtime_dependency_test.go @@ -15,7 +15,7 @@ import ( // source file from acquiring a runtime dependency on the fixture reader or an // on-disk capture path as gacha evolves. func TestServerRuntimeHasNoCaptureFixtureDependency(t *testing.T) { - root := filepath.Join("..", "..") + root := filepath.Join("..", "..", "..") for _, directory := range []string{filepath.Join(root, "cmd"), filepath.Join(root, "internal")} { err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { diff --git a/go/internal/gacha/schedule.go b/go/internal/server/gacha/schedule.go similarity index 100% rename from go/internal/gacha/schedule.go rename to go/internal/server/gacha/schedule.go diff --git a/go/internal/gacha/schedule_test.go b/go/internal/server/gacha/schedule_test.go similarity index 90% rename from go/internal/gacha/schedule_test.go rename to go/internal/server/gacha/schedule_test.go index 4fa600a..c9f8dcf 100644 --- a/go/internal/gacha/schedule_test.go +++ b/go/internal/server/gacha/schedule_test.go @@ -6,14 +6,14 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func TestVersionedScheduleMatchesOfficial23510GachaInfo(t *testing.T) { - seed, err := LoadScheduleSeed(filepath.Join("..", "..", "seed", "v2_34_13", "gacha_schedule.json"), "2.35.10") + seed, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "2.35.10") if err != nil { t.Fatal(err) } @@ -53,13 +53,13 @@ func TestScheduleSeedStrictValidation(t *testing.T) { if _, err := LoadScheduleSeed(path, "2.35.10"); err == nil { t.Fatal("unknown schedule field was accepted") } - if _, err := LoadScheduleSeed(filepath.Join("..", "..", "seed", "v2_34_13", "gacha_schedule.json"), "2.34.13"); err == nil { + if _, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "0.0.0"); err == nil { t.Fatal("wrong client version was accepted") } } func TestGachaInfoUsesInjectedScheduleAndEmptyAccountHasNoPreview(t *testing.T) { - seed, err := LoadScheduleSeed(filepath.Join("..", "..", "seed", "v2_34_13", "gacha_schedule.json"), "2.35.10") + seed, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "2.35.10") if err != nil { t.Fatal(err) } diff --git a/go/internal/gamedata/attributes.go b/go/internal/server/gamedata/attributes.go similarity index 100% rename from go/internal/gamedata/attributes.go rename to go/internal/server/gamedata/attributes.go diff --git a/go/internal/gamedata/attributes_test.go b/go/internal/server/gamedata/attributes_test.go similarity index 100% rename from go/internal/gamedata/attributes_test.go rename to go/internal/server/gamedata/attributes_test.go diff --git a/go/internal/gamedata/battle.go b/go/internal/server/gamedata/battle.go similarity index 99% rename from go/internal/gamedata/battle.go rename to go/internal/server/gamedata/battle.go index 0f83501..c29db66 100644 --- a/go/internal/gamedata/battle.go +++ b/go/internal/server/gamedata/battle.go @@ -7,7 +7,7 @@ import ( "os" "path/filepath" - "bd2server/internal/wire" + "bd2server/internal/server/wire" _ "modernc.org/sqlite" ) diff --git a/go/internal/gamedata/battle_test.go b/go/internal/server/gamedata/battle_test.go similarity index 100% rename from go/internal/gamedata/battle_test.go rename to go/internal/server/gamedata/battle_test.go diff --git a/go/internal/gamedata/char_awake.go b/go/internal/server/gamedata/char_awake.go similarity index 99% rename from go/internal/gamedata/char_awake.go rename to go/internal/server/gamedata/char_awake.go index 01393b2..4e6670c 100644 --- a/go/internal/gamedata/char_awake.go +++ b/go/internal/server/gamedata/char_awake.go @@ -36,7 +36,7 @@ type CharAwakeCharacterStage struct { MaximumLevel uint64 } -// CharAwakeDesign is immutable 2.34.13 design data. Account progress remains +// CharAwakeDesign is immutable 2.35.10 design data. Account progress remains // in CollectionStore and is indexed by UniqueCharId, as CharAwakeDBInfo is. type CharAwakeDesign struct { Characters map[uint64]CharAwakeCharacter diff --git a/go/internal/gamedata/char_awake_test.go b/go/internal/server/gamedata/char_awake_test.go similarity index 100% rename from go/internal/gamedata/char_awake_test.go rename to go/internal/server/gamedata/char_awake_test.go diff --git a/go/internal/gamedata/costume_potential.go b/go/internal/server/gamedata/costume_potential.go similarity index 100% rename from go/internal/gamedata/costume_potential.go rename to go/internal/server/gamedata/costume_potential.go diff --git a/go/internal/gamedata/costume_potential_test.go b/go/internal/server/gamedata/costume_potential_test.go similarity index 100% rename from go/internal/gamedata/costume_potential_test.go rename to go/internal/server/gamedata/costume_potential_test.go diff --git a/go/internal/gamedata/database.go b/go/internal/server/gamedata/database.go similarity index 94% rename from go/internal/gamedata/database.go rename to go/internal/server/gamedata/database.go index 540627f..83e599b 100644 --- a/go/internal/gamedata/database.go +++ b/go/internal/server/gamedata/database.go @@ -9,11 +9,11 @@ import ( "path/filepath" "strings" - "bd2server/internal/dbcrypt" + "bd2server/internal/server/dbcrypt" ) // DatabaseName maps a logical client DB name to its GameData archive entry. -// Version 1 is the current 2.34.13 DB schema generation. +// Version 1 is the current 2.35.10 DB schema generation. func DatabaseName(logical string) (string, error) { if logical == "" || strings.ContainsAny(logical, `/\\.`) { return "", fmt.Errorf("gamedata: invalid logical database name %q", logical) @@ -32,7 +32,7 @@ func ReadDatabase(root, version, logical string) ([]byte, error) { return readEntry(root, version, name, logical) } -// questDatabaseEntry is the member in the 2.34.13 GameData archive that +// questDatabaseEntry is the member in the 2.35.10 GameData archive that // contains the shared QuestTable* SQLite database, including QuestTable21. // Quest data is not stored in an individual per-pack database. const questDatabaseEntry = "9F251C63BC72551C681EE75D328FA090D56E444B" diff --git a/go/internal/gamedata/database_test.go b/go/internal/server/gamedata/database_test.go similarity index 98% rename from go/internal/gamedata/database_test.go rename to go/internal/server/gamedata/database_test.go index 94b8de6..4dc1723 100644 --- a/go/internal/gamedata/database_test.go +++ b/go/internal/server/gamedata/database_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "bd2server/internal/dbcrypt" + "bd2server/internal/server/dbcrypt" ) // These tests cover the production archive/decryption contract. Interactive diff --git a/go/internal/gamedata/equipment_craft.go b/go/internal/server/gamedata/equipment_craft.go similarity index 100% rename from go/internal/gamedata/equipment_craft.go rename to go/internal/server/gamedata/equipment_craft.go diff --git a/go/internal/gamedata/equipment_craft_test.go b/go/internal/server/gamedata/equipment_craft_test.go similarity index 100% rename from go/internal/gamedata/equipment_craft_test.go rename to go/internal/server/gamedata/equipment_craft_test.go diff --git a/go/internal/gamedata/equipment_gacha.go b/go/internal/server/gamedata/equipment_gacha.go similarity index 100% rename from go/internal/gamedata/equipment_gacha.go rename to go/internal/server/gamedata/equipment_gacha.go diff --git a/go/internal/gamedata/equipment_option_reroll.go b/go/internal/server/gamedata/equipment_option_reroll.go similarity index 100% rename from go/internal/gamedata/equipment_option_reroll.go rename to go/internal/server/gamedata/equipment_option_reroll.go diff --git a/go/internal/gamedata/equipment_option_reroll_test.go b/go/internal/server/gamedata/equipment_option_reroll_test.go similarity index 99% rename from go/internal/gamedata/equipment_option_reroll_test.go rename to go/internal/server/gamedata/equipment_option_reroll_test.go index b23cca9..aaf0d0e 100644 --- a/go/internal/gamedata/equipment_option_reroll_test.go +++ b/go/internal/server/gamedata/equipment_option_reroll_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestEquipmentOptionRerollDesignReadsCostsAndRollsUnlockedSlots(t *testing.T) { diff --git a/go/internal/gamedata/equipment_slots.go b/go/internal/server/gamedata/equipment_slots.go similarity index 100% rename from go/internal/gamedata/equipment_slots.go rename to go/internal/server/gamedata/equipment_slots.go diff --git a/go/internal/gamedata/equipment_slots_test.go b/go/internal/server/gamedata/equipment_slots_test.go similarity index 100% rename from go/internal/gamedata/equipment_slots_test.go rename to go/internal/server/gamedata/equipment_slots_test.go diff --git a/go/internal/gamedata/equipment_smelting.go b/go/internal/server/gamedata/equipment_smelting.go similarity index 99% rename from go/internal/gamedata/equipment_smelting.go rename to go/internal/server/gamedata/equipment_smelting.go index 2bdb4d9..21ace75 100644 --- a/go/internal/gamedata/equipment_smelting.go +++ b/go/internal/server/gamedata/equipment_smelting.go @@ -8,7 +8,7 @@ import ( "path/filepath" ) -// EquipmentSmeltingDesign contains only static 2.34.13 GameData facts. It does +// EquipmentSmeltingDesign contains only static 2.35.10 GameData facts. It does // not decide whether a lower-score result is applied or how mileage is paid: // those are server transactions and require an official response capture. type EquipmentSmeltingDesign struct { diff --git a/go/internal/gamedata/equipment_smelting_test.go b/go/internal/server/gamedata/equipment_smelting_test.go similarity index 100% rename from go/internal/gamedata/equipment_smelting_test.go rename to go/internal/server/gamedata/equipment_smelting_test.go diff --git a/go/internal/gamedata/equipment_ticket_gacha_test.go b/go/internal/server/gamedata/equipment_ticket_gacha_test.go similarity index 100% rename from go/internal/gamedata/equipment_ticket_gacha_test.go rename to go/internal/server/gamedata/equipment_ticket_gacha_test.go diff --git a/go/internal/gamedata/equipment_upgrade.go b/go/internal/server/gamedata/equipment_upgrade.go similarity index 100% rename from go/internal/gamedata/equipment_upgrade.go rename to go/internal/server/gamedata/equipment_upgrade.go diff --git a/go/internal/gamedata/equipment_upgrade_test.go b/go/internal/server/gamedata/equipment_upgrade_test.go similarity index 98% rename from go/internal/gamedata/equipment_upgrade_test.go rename to go/internal/server/gamedata/equipment_upgrade_test.go index 0580b7f..f86062f 100644 --- a/go/internal/gamedata/equipment_upgrade_test.go +++ b/go/internal/server/gamedata/equipment_upgrade_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestEquipmentUpgradeDesignReadsCostsAndRatio(t *testing.T) { diff --git a/go/internal/gamedata/gacha.go b/go/internal/server/gamedata/gacha.go similarity index 99% rename from go/internal/gamedata/gacha.go rename to go/internal/server/gamedata/gacha.go index e48509b..782b291 100644 --- a/go/internal/gamedata/gacha.go +++ b/go/internal/server/gamedata/gacha.go @@ -14,7 +14,7 @@ import ( _ "modernc.org/sqlite" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) const ( diff --git a/go/internal/gamedata/gacha_active_test.go b/go/internal/server/gamedata/gacha_active_test.go similarity index 100% rename from go/internal/gamedata/gacha_active_test.go rename to go/internal/server/gamedata/gacha_active_test.go diff --git a/go/internal/gamedata/gacha_fixed_test.go b/go/internal/server/gamedata/gacha_fixed_test.go similarity index 100% rename from go/internal/gamedata/gacha_fixed_test.go rename to go/internal/server/gamedata/gacha_fixed_test.go diff --git a/go/internal/gamedata/gacha_probability_test.go b/go/internal/server/gamedata/gacha_probability_test.go similarity index 100% rename from go/internal/gamedata/gacha_probability_test.go rename to go/internal/server/gamedata/gacha_probability_test.go diff --git a/go/internal/gamedata/growth.go b/go/internal/server/gamedata/growth.go similarity index 100% rename from go/internal/gamedata/growth.go rename to go/internal/server/gamedata/growth.go diff --git a/go/internal/gamedata/inventory_slots.go b/go/internal/server/gamedata/inventory_slots.go similarity index 100% rename from go/internal/gamedata/inventory_slots.go rename to go/internal/server/gamedata/inventory_slots.go diff --git a/go/internal/gamedata/inventory_slots_test.go b/go/internal/server/gamedata/inventory_slots_test.go similarity index 97% rename from go/internal/gamedata/inventory_slots_test.go rename to go/internal/server/gamedata/inventory_slots_test.go index e6e886c..9a721d3 100644 --- a/go/internal/gamedata/inventory_slots_test.go +++ b/go/internal/server/gamedata/inventory_slots_test.go @@ -4,7 +4,7 @@ import ( "database/sql" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" _ "modernc.org/sqlite" ) diff --git a/go/internal/gamedata/missions.go b/go/internal/server/gamedata/missions.go similarity index 100% rename from go/internal/gamedata/missions.go rename to go/internal/server/gamedata/missions.go diff --git a/go/internal/gamedata/pack_transition.go b/go/internal/server/gamedata/pack_transition.go similarity index 100% rename from go/internal/gamedata/pack_transition.go rename to go/internal/server/gamedata/pack_transition.go diff --git a/go/internal/gamedata/pictorial.go b/go/internal/server/gamedata/pictorial.go similarity index 99% rename from go/internal/gamedata/pictorial.go rename to go/internal/server/gamedata/pictorial.go index a85316c..3b42500 100644 --- a/go/internal/gamedata/pictorial.go +++ b/go/internal/server/gamedata/pictorial.go @@ -71,7 +71,7 @@ type CharacterPictorialMeta struct { UsePackTemporary bool } -// PictorialDesign is immutable 2.34.13 design data. Dynamic account state is +// PictorialDesign is immutable 2.35.10 design data. Dynamic account state is // deliberately not copied here: callers match their current ownership against // these rows each time they build PictorialBookInfo or AllCharRefresh. type PictorialDesign struct { diff --git a/go/internal/gamedata/promotion.go b/go/internal/server/gamedata/promotion.go similarity index 100% rename from go/internal/gamedata/promotion.go rename to go/internal/server/gamedata/promotion.go diff --git a/go/internal/gamedata/promotion_test.go b/go/internal/server/gamedata/promotion_test.go similarity index 100% rename from go/internal/gamedata/promotion_test.go rename to go/internal/server/gamedata/promotion_test.go diff --git a/go/internal/gamedata/quest_formation.go b/go/internal/server/gamedata/quest_formation.go similarity index 100% rename from go/internal/gamedata/quest_formation.go rename to go/internal/server/gamedata/quest_formation.go diff --git a/go/internal/gamedata/quest_formation_test.go b/go/internal/server/gamedata/quest_formation_test.go similarity index 99% rename from go/internal/gamedata/quest_formation_test.go rename to go/internal/server/gamedata/quest_formation_test.go index 746a1e9..198a3b4 100644 --- a/go/internal/gamedata/quest_formation_test.go +++ b/go/internal/server/gamedata/quest_formation_test.go @@ -7,7 +7,7 @@ import ( "reflect" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestLoadQuestFormationsDB(t *testing.T) { diff --git a/go/internal/gamedata/quests.go b/go/internal/server/gamedata/quests.go similarity index 100% rename from go/internal/gamedata/quests.go rename to go/internal/server/gamedata/quests.go diff --git a/go/internal/gamedata/random_box.go b/go/internal/server/gamedata/random_box.go similarity index 100% rename from go/internal/gamedata/random_box.go rename to go/internal/server/gamedata/random_box.go diff --git a/go/internal/gamedata/random_box_test.go b/go/internal/server/gamedata/random_box_test.go similarity index 100% rename from go/internal/gamedata/random_box_test.go rename to go/internal/server/gamedata/random_box_test.go diff --git a/go/internal/gamedata/stat_design.go b/go/internal/server/gamedata/stat_design.go similarity index 99% rename from go/internal/gamedata/stat_design.go rename to go/internal/server/gamedata/stat_design.go index a02015d..612eb78 100644 --- a/go/internal/gamedata/stat_design.go +++ b/go/internal/server/gamedata/stat_design.go @@ -8,7 +8,7 @@ import ( "os" "path/filepath" - "bd2server/internal/wire" + "bd2server/internal/server/wire" _ "modernc.org/sqlite" ) diff --git a/go/internal/gamedata/talent_growth.go b/go/internal/server/gamedata/talent_growth.go similarity index 100% rename from go/internal/gamedata/talent_growth.go rename to go/internal/server/gamedata/talent_growth.go diff --git a/go/internal/gamedata/talent_growth_test.go b/go/internal/server/gamedata/talent_growth_test.go similarity index 99% rename from go/internal/gamedata/talent_growth_test.go rename to go/internal/server/gamedata/talent_growth_test.go index f45616e..665731a 100644 --- a/go/internal/gamedata/talent_growth_test.go +++ b/go/internal/server/gamedata/talent_growth_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestTalentGrowthDesignJoinsCharacterAndUsesCumulativeExperience(t *testing.T) { diff --git a/go/internal/gamedata/validate.go b/go/internal/server/gamedata/validate.go similarity index 100% rename from go/internal/gamedata/validate.go rename to go/internal/server/gamedata/validate.go diff --git a/go/internal/gamedata/validate_test.go b/go/internal/server/gamedata/validate_test.go similarity index 100% rename from go/internal/gamedata/validate_test.go rename to go/internal/server/gamedata/validate_test.go diff --git a/go/internal/mail/mail.go b/go/internal/server/mail/mail.go similarity index 97% rename from go/internal/mail/mail.go rename to go/internal/server/mail/mail.go index 042110d..a950878 100644 --- a/go/internal/mail/mail.go +++ b/go/internal/server/mail/mail.go @@ -14,11 +14,11 @@ import ( "sync" "time" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) const packetCode = 131 @@ -89,7 +89,7 @@ func Load(path string) (*Starter, error) { } func (s *Starter) Validate() error { - if s == nil || s.Version != versionconfig.Protocol() { + if s == nil || s.Version != versionconfig.State() { return errors.New("mail: wrong starter version") } if s.MailCount != uint64(len(s.Mails))+1 { @@ -225,7 +225,7 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve return nil, errors.New("mail: starter mail ID exhausted") } s := &Service{Starter: starter, storage: entries, inventory: inventory, wallet: wallet, - state: stateSnapshot{Version: versionconfig.Protocol(), NextDynamicMailID: starter.MaxMailID + 1}, + state: stateSnapshot{Version: versionconfig.State(), NextDynamicMailID: starter.MaxMailID + 1}, dynamic: map[uint64]MailDBInfo{}, issued: map[string]uint64{}} b, err := storage.Load("mail") if err != nil { @@ -240,7 +240,7 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve if err := stateio.RequireExactJSONObject(b, "version", "opened", "next_dynamic_mail_id"); err != nil { return nil, fmt.Errorf("mail: incompatible state layout: %w", err) } - if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.Protocol() || s.state.NextDynamicMailID == 0 { + if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() || s.state.NextDynamicMailID == 0 { return nil, errors.New("mail: malformed state") } rawDynamic, err := entries.ListEntries("mail", "dynamic") diff --git a/go/internal/mail/mail_test.go b/go/internal/server/mail/mail_test.go similarity index 90% rename from go/internal/mail/mail_test.go rename to go/internal/server/mail/mail_test.go index f43db23..21e08ba 100644 --- a/go/internal/mail/mail_test.go +++ b/go/internal/server/mail/mail_test.go @@ -7,15 +7,15 @@ import ( "testing" "time" - "bd2server/internal/fixture" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/fixture" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func TestStarterAnswersMailInfoWithoutCapture(t *testing.T) { - seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "mail.json")) + seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "mail.json")) if err != nil { t.Fatal(err) } @@ -32,9 +32,9 @@ func TestStarterAnswersMailInfoWithoutCapture(t *testing.T) { } func TestDynamicCompensationMailPersistsAndIsIdempotent(t *testing.T) { - seed := &Starter{Version: "2.34.13", MailCount: 1, MaxMailID: 100} + seed := &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100} storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.34.13"}) + inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -72,11 +72,11 @@ func TestDynamicCompensationMailPersistsAndIsIdempotent(t *testing.T) { } func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) { - seed := &Starter{Version: "2.34.13", MailCount: 3, MaxMailID: 12, Mails: []MailDBInfo{ + seed := &Starter{Version: "2.35.10", MailCount: 3, MaxMailID: 12, Mails: []MailDBInfo{ {MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{3, 4}, RewardIDs: []uint64{0, 0}, RewardCounts: []uint64{70, 123456789}}, {MailID: 12, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{8}, RewardIDs: []uint64{9}, RewardCounts: []uint64{10}}, }} - starter := &player.Starter{Version: "2.34.13"} + starter := &player.Starter{Version: "2.35.10"} storage := stateio.NewMemory() inv, err := player.OpenInventory(storage, starter) if err != nil { @@ -132,11 +132,11 @@ func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) { } func TestMailOpenGrantsNonResourceItemDBInfoType(t *testing.T) { - seed := &Starter{Version: "2.34.13", MailCount: 2, MaxMailID: 13, Mails: []MailDBInfo{{ + seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 13, Mails: []MailDBInfo{{ MailID: 13, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{14}, RewardIDs: []uint64{1}, RewardCounts: []uint64{2}, }}} - starter := &player.Starter{Version: "2.34.13"} + starter := &player.Starter{Version: "2.35.10"} storage := stateio.NewMemory() inv, err := player.OpenInventory(storage, starter) if err != nil { @@ -162,12 +162,12 @@ func TestMailOpenGrantsNonResourceItemDBInfoType(t *testing.T) { } func TestMailOpenGrantsCatalystCurrencyAndPersists(t *testing.T) { - seed := &Starter{Version: "2.34.13", MailCount: 2, MaxMailID: 14, Mails: []MailDBInfo{{ + seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 14, Mails: []MailDBInfo{{ MailID: 14, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{12}, RewardIDs: []uint64{0}, RewardCounts: []uint64{250}, }}} storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.34.13"}) + inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -220,7 +220,7 @@ func TestMailOpenGrantsCatalystCurrencyAndPersists(t *testing.T) { func TestWatchedSeedReloadsOnlyValidAtomicReplacement(t *testing.T) { dir := t.TempDir() seedPath := filepath.Join(dir, "seed.json") - first := &Starter{Version: "2.34.13", MailCount: 2, MaxMailID: 11, Mails: []MailDBInfo{{MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10}}} + first := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 11, Mails: []MailDBInfo{{MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10}}} if err := first.Write(seedPath); err != nil { t.Fatal(err) } @@ -229,7 +229,7 @@ func TestWatchedSeedReloadsOnlyValidAtomicReplacement(t *testing.T) { t.Fatal(err) } storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.34.13"}) + inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -250,7 +250,7 @@ func TestWatchedSeedReloadsOnlyValidAtomicReplacement(t *testing.T) { } else if id, _, _ := wire.Varint(mustFirstMail(t, response), 1); id != 11 { t.Fatalf("initial mail ID=%d", id) } - second := &Starter{Version: "2.34.13", MailCount: 2, MaxMailID: 12, Mails: []MailDBInfo{{MailID: 12, MailType: 2, ExpiresAt: 100, SentAt: 10}}} + second := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 12, Mails: []MailDBInfo{{MailID: 12, MailType: 2, ExpiresAt: 100, SentAt: 10}}} if err := second.Write(seedPath); err != nil { t.Fatal(err) } @@ -274,12 +274,12 @@ func TestWatchedSeedReloadsOnlyValidAtomicReplacement(t *testing.T) { func TestExpandedWatchedSeedAdvancesDynamicMailAllocator(t *testing.T) { dir := t.TempDir() seedPath := filepath.Join(dir, "seed.json") - first := &Starter{Version: "2.34.13", MailCount: 2, MaxMailID: 11, Mails: []MailDBInfo{{MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10}}} + first := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 11, Mails: []MailDBInfo{{MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10}}} if err := first.Write(seedPath); err != nil { t.Fatal(err) } storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.34.13"}) + inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -297,7 +297,7 @@ func TestExpandedWatchedSeedAdvancesDynamicMailAllocator(t *testing.T) { if err := service.AttachSeedPath(seedPath); err != nil { t.Fatal(err) } - second := &Starter{Version: "2.34.13", MailCount: 3, MaxMailID: 101, Mails: []MailDBInfo{ + second := &Starter{Version: "2.35.10", MailCount: 3, MaxMailID: 101, Mails: []MailDBInfo{ {MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10}, {MailID: 101, MailType: 2, ExpiresAt: 100, SentAt: 10}, }} @@ -346,7 +346,7 @@ func TestMailOpenAcceptsOfficialPackedRequest(t *testing.T) { } func TestStarterMatchesInitialAccountSample(t *testing.T) { - set, err := fixture.Load(filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")) + set, err := fixture.Load(filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")) if err != nil { t.Skipf("optional capture unavailable: %v", err) } @@ -358,7 +358,7 @@ func TestStarterMatchesInitialAccountSample(t *testing.T) { if err != nil { t.Fatal(err) } - seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "mail.json")) + seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "mail.json")) if err != nil { t.Fatal(err) } @@ -369,7 +369,7 @@ func TestStarterMatchesInitialAccountSample(t *testing.T) { } func TestValidateRejectsRewardLengthMismatch(t *testing.T) { - seed := &Starter{Version: "2.34.13", Mails: []MailDBInfo{{MailID: 1, MailType: 2, ExpiresAt: 1, SentAt: 1, RewardTypes: []uint64{8}, RewardIDs: []uint64{1}}}, MailCount: 2, MaxMailID: 1} + seed := &Starter{Version: "2.35.10", Mails: []MailDBInfo{{MailID: 1, MailType: 2, ExpiresAt: 1, SentAt: 1, RewardTypes: []uint64{8}, RewardIDs: []uint64{1}}}, MailCount: 2, MaxMailID: 1} if seed.Validate() == nil { t.Fatal("accepted bad rewards") } diff --git a/go/internal/missions/missions.go b/go/internal/server/missions/missions.go similarity index 98% rename from go/internal/missions/missions.go rename to go/internal/server/missions/missions.go index 3b50a7b..3c50e38 100644 --- a/go/internal/missions/missions.go +++ b/go/internal/server/missions/missions.go @@ -13,12 +13,12 @@ import ( "sync" "time" - "bd2server/internal/gamedata" - "bd2server/internal/mail" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/mail" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) const ( @@ -67,7 +67,7 @@ func Open(storage stateio.Store, design *gamedata.MissionDesign, inventory *play } now := time.Now().UTC() s := &Service{storage: storage, design: design, inventory: inventory, now: time.Now, state: snapshot{ - Version: versionconfig.Protocol(), DailyPeriod: dailyPeriod(now), WeeklyPeriod: weeklyPeriod(now), Progress: map[string]uint64{}, + Version: versionconfig.State(), DailyPeriod: dailyPeriod(now), WeeklyPeriod: weeklyPeriod(now), Progress: map[string]uint64{}, }} b, err := storage.Load("missions") if err != nil { @@ -79,7 +79,7 @@ func Open(storage stateio.Store, design *gamedata.MissionDesign, inventory *play if err := stateio.RequireExactJSONObject(b, "version", "daily_period", "weekly_period", "completed", "claimed", "progress"); err != nil { return nil, fmt.Errorf("missions: incompatible state layout: %w", err) } - if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.Protocol() || s.state.DailyPeriod == "" || s.state.WeeklyPeriod == "" { + if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() || s.state.DailyPeriod == "" || s.state.WeeklyPeriod == "" { return nil, errors.New("missions: malformed state") } if s.state.Progress == nil { diff --git a/go/internal/missions/missions_test.go b/go/internal/server/missions/missions_test.go similarity index 93% rename from go/internal/missions/missions_test.go rename to go/internal/server/missions/missions_test.go index 44f544e..c12208d 100644 --- a/go/internal/missions/missions_test.go +++ b/go/internal/server/missions/missions_test.go @@ -4,11 +4,11 @@ import ( "testing" "time" - "bd2server/internal/gamedata" - "bd2server/internal/mail" - "bd2server/internal/player" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/mail" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func TestAchievementClaimsAcceptPackedAndUnpackedIDs(t *testing.T) { @@ -39,7 +39,7 @@ func TestDailyRolloverMailsCompletedMissionAndSectionRewards(t *testing.T) { Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{}, } storage := stateio.NewMemory() - starter := &player.Starter{Version: "2.34.13"} + starter := &player.Starter{Version: "2.35.10"} inv, err := player.OpenInventory(storage, starter) if err != nil { t.Fatal(err) @@ -48,7 +48,7 @@ func TestDailyRolloverMailsCompletedMissionAndSectionRewards(t *testing.T) { if err != nil { t.Fatal(err) } - mailbox, err := mail.OpenService(storage, &mail.Starter{Version: "2.34.13", MailCount: 1, MaxMailID: 100}, inv, wallet) + mailbox, err := mail.OpenService(storage, &mail.Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100}, inv, wallet) if err != nil { t.Fatal(err) } @@ -110,7 +110,7 @@ func TestOfficialBulkAchievementRequestMayOmitContentsGroup(t *testing.T) { {ContentsGroup: 7, GroupID: 1, ID: 1}: {AddExp: 3}, }, } - starter := &player.Starter{Version: "2.34.13"} + starter := &player.Starter{Version: "2.35.10"} inventory, err := player.OpenInventory(stateio.NewMemory(), starter) if err != nil { t.Fatal(err) @@ -141,7 +141,7 @@ func TestOfficialBulkSectionRequestAcceptsDefaultGroupType(t *testing.T) { Missions: map[gamedata.MissionKey][]gamedata.Reward{}, Sections: map[gamedata.SectionRewardKey]gamedata.SectionRewardDesign{}, Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{}, } - starter := &player.Starter{Version: "2.34.13"} + starter := &player.Starter{Version: "2.35.10"} inventory, err := player.OpenInventory(stateio.NewMemory(), starter) if err != nil { t.Fatal(err) @@ -168,7 +168,7 @@ func TestMissionProgressIsNotClaimedAndDailyTypeZeroIsValid(t *testing.T) { Conditions: map[gamedata.MissionKey]gamedata.MissionCondition{key: {TargetValue: 1}}, Sections: map[gamedata.SectionRewardKey]gamedata.SectionRewardDesign{}, Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{}, } - starter := &player.Starter{Version: "2.34.13"} + starter := &player.Starter{Version: "2.35.10"} storage := stateio.NewMemory() inv, err := player.OpenInventory(storage, starter) if err != nil { diff --git a/go/internal/pictorial/service.go b/go/internal/server/pictorial/service.go similarity index 98% rename from go/internal/pictorial/service.go rename to go/internal/server/pictorial/service.go index 28c708c..81a8d59 100644 --- a/go/internal/pictorial/service.go +++ b/go/internal/server/pictorial/service.go @@ -9,9 +9,9 @@ import ( "sort" "sync" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" ) type Owned interface { diff --git a/go/internal/pictorial/service_test.go b/go/internal/server/pictorial/service_test.go similarity index 96% rename from go/internal/pictorial/service_test.go rename to go/internal/server/pictorial/service_test.go index ef65010..611eb29 100644 --- a/go/internal/pictorial/service_test.go +++ b/go/internal/server/pictorial/service_test.go @@ -9,9 +9,9 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" ) type ownedState struct { @@ -37,7 +37,7 @@ func TestOfficialPictorialProgression(t *testing.T) { if err != nil { t.Fatal(err) } - starter, err := player.Load(filepath.Join("..", "..", "seed", "v2_34_13", "starter_player.json")) + starter, err := player.Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "starter_player.json")) if err != nil { t.Fatal(err) } @@ -46,7 +46,7 @@ func TestOfficialPictorialProgression(t *testing.T) { RewardCostume player.Costume `json:"reward_costume"` StoryCharacters []player.Character `json:"story_characters"` } - data, err := os.ReadFile(filepath.Join("..", "..", "seed", "v2_34_13", "world.json")) + data, err := os.ReadFile(filepath.Join("..", "..", "..", "seed", "v2_35_10", "world.json")) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/char_awake.go b/go/internal/server/player/char_awake.go similarity index 99% rename from go/internal/player/char_awake.go rename to go/internal/server/player/char_awake.go index 78a72e5..aa78a83 100644 --- a/go/internal/player/char_awake.go +++ b/go/internal/server/player/char_awake.go @@ -6,8 +6,8 @@ import ( "sort" "strconv" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) type CharAwakeService struct { diff --git a/go/internal/player/char_awake_test.go b/go/internal/server/player/char_awake_test.go similarity index 98% rename from go/internal/player/char_awake_test.go rename to go/internal/server/player/char_awake_test.go index 6ee9088..0018f5a 100644 --- a/go/internal/player/char_awake_test.go +++ b/go/internal/server/player/char_awake_test.go @@ -4,14 +4,14 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) func newCharAwakeHarness(t *testing.T) (*CharAwakeService, *CollectionStore, *Inventory, *Wallet, []Item) { t.Helper() dir := t.TempDir() - starter := &Starter{Version: "2.34.13"} + starter := &Starter{Version: "2.35.10"} inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) if err != nil { t.Fatal(err) diff --git a/go/internal/player/collection.go b/go/internal/server/player/collection.go similarity index 99% rename from go/internal/player/collection.go rename to go/internal/server/player/collection.go index 5f9e5ed..a2f43b5 100644 --- a/go/internal/player/collection.go +++ b/go/internal/server/player/collection.go @@ -9,9 +9,9 @@ import ( "sync" "time" - "bd2server/internal/gamedata" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" ) type CostumeUpgrade struct { @@ -148,7 +148,7 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore, return nil, errors.New("player: collection store requires atomic entry storage") } s := &CollectionStore{store: entries, base: append([]Costume(nil), base...), data: collectionSnapshot{ - Version: versionconfig.Protocol(), NextCharacterIndex: 920000001, NextCostumeIndex: 930000001, + Version: versionconfig.State(), NextCharacterIndex: 920000001, NextCostumeIndex: 930000001, BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, GachaSelectionChanges: map[string]uint64{}, CostumePotential: map[string][]uint64{}, CharAwake: map[string]CharAwakeProgress{}, @@ -174,7 +174,7 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore, if err := loadCollectionEntries(entries, &s.data); err != nil { return nil, err } - if s.data.Version != versionconfig.Protocol() || s.data.NextCharacterIndex < 920000001 || s.data.NextCostumeIndex < 930000001 { + if s.data.Version != versionconfig.State() || s.data.NextCharacterIndex < 920000001 || s.data.NextCostumeIndex < 930000001 { return nil, errors.New("player: invalid collection save") } if s.data.BaseCostumeLevels == nil { diff --git a/go/internal/player/collection_entries.go b/go/internal/server/player/collection_entries.go similarity index 99% rename from go/internal/player/collection_entries.go rename to go/internal/server/player/collection_entries.go index afa260f..feca994 100644 --- a/go/internal/player/collection_entries.go +++ b/go/internal/server/player/collection_entries.go @@ -7,7 +7,7 @@ import ( "sort" "strconv" - "bd2server/internal/stateio" + "bd2server/internal/server/stateio" ) const collectionDomain = "collection" diff --git a/go/internal/player/collection_entries_test.go b/go/internal/server/player/collection_entries_test.go similarity index 97% rename from go/internal/player/collection_entries_test.go rename to go/internal/server/player/collection_entries_test.go index 43bfed8..ac8f6f6 100644 --- a/go/internal/player/collection_entries_test.go +++ b/go/internal/server/player/collection_entries_test.go @@ -6,7 +6,7 @@ import ( "reflect" "testing" - "bd2server/internal/stateio" + "bd2server/internal/server/stateio" ) type collectionWriteSpy struct { @@ -76,7 +76,7 @@ func TestCollectionPersistsOnlyChangedEntries(t *testing.T) { func TestCollectionRejectsInlineLedgerInFinalCore(t *testing.T) { storage := stateio.NewMemory() core := map[string]any{ - "version": "2.34.13", "next_character_index": 920000001, + "version": "2.35.10", "next_character_index": 920000001, "next_costume_index": 930000001, "grants": map[string]any{}, } encoded, err := json.Marshal(core) diff --git a/go/internal/player/collection_test.go b/go/internal/server/player/collection_test.go similarity index 99% rename from go/internal/player/collection_test.go rename to go/internal/server/player/collection_test.go index cb5eb7e..1fd43d5 100644 --- a/go/internal/player/collection_test.go +++ b/go/internal/server/player/collection_test.go @@ -4,7 +4,7 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" + "bd2server/internal/server/gamedata" ) func TestEarnedQuestCostumeSharesPotentialLedgerAndKeepsSingleOwnedInstance(t *testing.T) { diff --git a/go/internal/player/costume_potential.go b/go/internal/server/player/costume_potential.go similarity index 98% rename from go/internal/player/costume_potential.go rename to go/internal/server/player/costume_potential.go index 3c2004b..63ba880 100644 --- a/go/internal/player/costume_potential.go +++ b/go/internal/server/player/costume_potential.go @@ -8,8 +8,8 @@ import ( "strconv" "strings" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) type CostumePotentialService struct { diff --git a/go/internal/player/costume_potential_test.go b/go/internal/server/player/costume_potential_test.go similarity index 96% rename from go/internal/player/costume_potential_test.go rename to go/internal/server/player/costume_potential_test.go index f6f2b3f..bcc5847 100644 --- a/go/internal/player/costume_potential_test.go +++ b/go/internal/server/player/costume_potential_test.go @@ -5,13 +5,13 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) func TestCostumeNodeActivationSupportsSingleAndOneClickSets(t *testing.T) { dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/entry_ledger.go b/go/internal/server/player/entry_ledger.go similarity index 96% rename from go/internal/player/entry_ledger.go rename to go/internal/server/player/entry_ledger.go index a37b77a..cb23aba 100644 --- a/go/internal/player/entry_ledger.go +++ b/go/internal/server/player/entry_ledger.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "bd2server/internal/stateio" + "bd2server/internal/server/stateio" ) func loadBoolEntries(store stateio.AtomicEntryStore, domain, bucket string) (map[string]bool, error) { diff --git a/go/internal/player/equipment.go b/go/internal/server/player/equipment.go similarity index 99% rename from go/internal/player/equipment.go rename to go/internal/server/player/equipment.go index 3fe7f16..e01a6ab 100644 --- a/go/internal/player/equipment.go +++ b/go/internal/server/player/equipment.go @@ -13,10 +13,10 @@ import ( "sync" "unicode/utf8" - "bd2server/internal/gamedata" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) // Equipment is one server-owned equipment instance. The immutable definition @@ -178,7 +178,7 @@ func OpenEquipmentInventory(store stateio.Store) (*EquipmentInventory, error) { return nil, errors.New("player: nil equipment store") } s := &EquipmentInventory{store: entries, smeltCache: make(map[string]smeltingReply), presets: make(map[equipmentPresetKey]equipmentPreset), owned: equipmentSnapshot{ - Version: versionconfig.Protocol(), NextIndex: 910000001, Granted: map[string]uint64{}, + Version: versionconfig.State(), NextIndex: 910000001, Granted: map[string]uint64{}, }} data, err := store.Load("equipment") if err != nil { @@ -204,7 +204,7 @@ func OpenEquipmentInventory(store stateio.Store) (*EquipmentInventory, error) { } else if err := stateio.RequireNoEntries(entries, "equipment", "equipment", "granted", "reroll_pending", "presets"); err != nil { return nil, fmt.Errorf("player: invalid equipment storage: %w", err) } - if s.owned.Version != versionconfig.Protocol() || s.owned.NextIndex < 910000001 { + if s.owned.Version != versionconfig.State() || s.owned.NextIndex < 910000001 { return nil, errors.New("player: invalid saved equipment") } s.owned.Granted, err = loadUintEntries(entries, "equipment", "granted") diff --git a/go/internal/player/equipment_batch_preset.go b/go/internal/server/player/equipment_batch_preset.go similarity index 99% rename from go/internal/player/equipment_batch_preset.go rename to go/internal/server/player/equipment_batch_preset.go index e2c0ff6..7623e07 100644 --- a/go/internal/player/equipment_batch_preset.go +++ b/go/internal/server/player/equipment_batch_preset.go @@ -10,8 +10,8 @@ import ( "strings" "unicode/utf8" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) const equipmentSlotCount = 5 diff --git a/go/internal/player/equipment_batch_use_test.go b/go/internal/server/player/equipment_batch_use_test.go similarity index 99% rename from go/internal/player/equipment_batch_use_test.go rename to go/internal/server/player/equipment_batch_use_test.go index b96cee8..10d816e 100644 --- a/go/internal/player/equipment_batch_use_test.go +++ b/go/internal/server/player/equipment_batch_use_test.go @@ -6,7 +6,7 @@ import ( "sort" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestEquipmentBatchUseOneClickClearPreservesUnfilteredSlots(t *testing.T) { @@ -155,7 +155,7 @@ type equipmentBatchUseFixture struct { func newEquipmentBatchUseFixture(t *testing.T) *equipmentBatchUseFixture { t.Helper() dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/equipment_craft.go b/go/internal/server/player/equipment_craft.go similarity index 99% rename from go/internal/player/equipment_craft.go rename to go/internal/server/player/equipment_craft.go index 604559c..17c8fa5 100644 --- a/go/internal/player/equipment_craft.go +++ b/go/internal/server/player/equipment_craft.go @@ -7,8 +7,8 @@ import ( "sort" "strconv" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) const equipmentBatchLimit = 100 diff --git a/go/internal/player/equipment_craft_runtime.go b/go/internal/server/player/equipment_craft_runtime.go similarity index 100% rename from go/internal/player/equipment_craft_runtime.go rename to go/internal/server/player/equipment_craft_runtime.go diff --git a/go/internal/player/equipment_craft_test.go b/go/internal/server/player/equipment_craft_test.go similarity index 97% rename from go/internal/player/equipment_craft_test.go rename to go/internal/server/player/equipment_craft_test.go index b96a7c0..9f70777 100644 --- a/go/internal/player/equipment_craft_test.go +++ b/go/internal/server/player/equipment_craft_test.go @@ -6,8 +6,8 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) func TestEquipMakingAgainstInstalledCurrentVersion(t *testing.T) { @@ -16,7 +16,7 @@ func TestEquipMakingAgainstInstalledCurrentVersion(t *testing.T) { t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test") } dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -103,7 +103,7 @@ func TestEquipMakingAgainstInstalledCurrentVersion(t *testing.T) { func TestEquipBreakUsesLevelRewardAndReplays(t *testing.T) { dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -145,7 +145,7 @@ func TestEquipBreakUsesLevelRewardAndReplays(t *testing.T) { func TestEquipUpgradeToBreakAutoUpgradesThenRemoves(t *testing.T) { dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/equipment_option_reroll_test.go b/go/internal/server/player/equipment_option_reroll_test.go similarity index 99% rename from go/internal/player/equipment_option_reroll_test.go rename to go/internal/server/player/equipment_option_reroll_test.go index 008a7ff..b7ca0f6 100644 --- a/go/internal/player/equipment_option_reroll_test.go +++ b/go/internal/server/player/equipment_option_reroll_test.go @@ -5,8 +5,8 @@ import ( "reflect" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) func TestEquipmentOptionRerollLocksChargesReplaysAndRecoversPending(t *testing.T) { @@ -364,7 +364,7 @@ func newEquipmentOptionRerollFixture(t *testing.T) *equipmentOptionRerollFixture if err != nil { t.Fatal(err) } - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/equipment_preset_test.go b/go/internal/server/player/equipment_preset_test.go similarity index 97% rename from go/internal/player/equipment_preset_test.go rename to go/internal/server/player/equipment_preset_test.go index cd544d1..e3b3226 100644 --- a/go/internal/player/equipment_preset_test.go +++ b/go/internal/server/player/equipment_preset_test.go @@ -5,12 +5,12 @@ import ( "reflect" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestEquipmentPresetSaveInfoRenameAndRestart(t *testing.T) { dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -82,7 +82,7 @@ func TestEquipmentPresetSaveInfoRenameAndRestart(t *testing.T) { func TestEquipmentPresetRejectsForgedEquipment(t *testing.T) { dir := t.TempDir() - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) characters, _ := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 100, ID: 350, Level: 20}, {InvenIndex: 200, ID: 351, Level: 20}}, inventory, "", "") equipment, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) _ = equipment.AttachSlots(map[uint64]uint64{10: 0}) @@ -113,7 +113,7 @@ func TestEquipmentPresetRejectsForgedEquipment(t *testing.T) { func TestEquipmentPresetNameChangeCreatesEmptySlot(t *testing.T) { dir := t.TempDir() - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) characters, _ := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 100, ID: 350, Level: 20}}, inventory, "", "") equipment, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) _ = equipment.AttachCharacters(characters) diff --git a/go/internal/player/equipment_test.go b/go/internal/server/player/equipment_test.go similarity index 98% rename from go/internal/player/equipment_test.go rename to go/internal/server/player/equipment_test.go index d50909c..b060880 100644 --- a/go/internal/player/equipment_test.go +++ b/go/internal/server/player/equipment_test.go @@ -5,8 +5,8 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) func TestEquipmentUpgradeAndSequenceUseGameDataCosts(t *testing.T) { @@ -28,7 +28,7 @@ func TestEquipmentUpgradeAndSequenceUseGameDataCosts(t *testing.T) { {954, 2}: {Level: 2, Costs: []gamedata.PromotionCost{{Type: 4, Count: 300}}, SuccessRatio: 1}, }, } - items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -90,7 +90,7 @@ func TestEquipmentSmeltingImprovesByTotalScoreAndReplaysWithoutSecondCharge(t *t if err != nil { t.Fatal(err) } - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -162,7 +162,7 @@ func TestEquipmentSmeltingImprovesByTotalScoreAndReplaysWithoutSecondCharge(t *t func TestEquipmentSmeltingFailureConsumesAndReturnsCandidateGrades(t *testing.T) { dir := t.TempDir() wallet, _ := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000}) - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) materials, _ := inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 30}}) store, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {1, 0, 0, 0}, {1, 0, 0, 0}}), wallet, inventory); err != nil { @@ -208,7 +208,7 @@ func TestEquipmentSmeltingFailureConsumesAndReturnsCandidateGrades(t *testing.T) func TestEquipmentSequenceSmeltingRepeatsAndStopsAtTargetScore(t *testing.T) { dir := t.TempDir() wallet, _ := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000}) - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) _, _ = inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 300}}) store, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {0, 0, 0, 1}, {0, 0, 0, 1}}), wallet, inventory); err != nil { @@ -244,7 +244,7 @@ func TestEquipmentSequenceSmeltingRepeatsAndStopsAtTargetScore(t *testing.T) { func TestEquipmentSequenceSmeltingPacketLimitIsNotTerminal(t *testing.T) { dir := t.TempDir() wallet, _ := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000}) - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) _, _ = inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 300}}) store, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {1, 0, 0, 0}, {1, 0, 0, 0}}), wallet, inventory); err != nil { @@ -296,7 +296,7 @@ func TestEquipmentUpgradeFailureConsumesGoldWithoutLevel(t *testing.T) { t.Fatal(err) } design := &gamedata.EquipmentUpgradeDesign{MaxLevel: map[uint64]uint64{1: 1}, Group: map[uint64]uint64{1: 2}, Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{{2, 0}: {Costs: []gamedata.PromotionCost{{Type: 4, Count: 50}}, SuccessRatio: 0}}} - items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -330,7 +330,7 @@ func TestEquipmentUpgradeConsumesResourceStacksAndSequenceStopsWhenEmpty(t *test if err != nil { t.Fatal(err) } - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -547,7 +547,7 @@ func TestEquipmentGrantPersistsAndIsIdempotent(t *testing.T) { func TestEquipmentUsePersistsCharacterBinding(t *testing.T) { dir := t.TempDir() - starter := &Starter{Version: "2.34.13"} + starter := &Starter{Version: "2.35.10"} items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) if err != nil { t.Fatal(err) @@ -604,7 +604,7 @@ func TestEquipmentUsePersistsCharacterBinding(t *testing.T) { func TestEquipmentClearPersistsUnboundEquipmentAndReturnsCharacter(t *testing.T) { dir := t.TempDir() - items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -662,7 +662,7 @@ func TestEquipmentClearPersistsUnboundEquipmentAndReturnsCharacter(t *testing.T) func TestEquipmentChangeReplacesOnlyMatchingGameDataSlot(t *testing.T) { dir := t.TempDir() - items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/gacha_state.go b/go/internal/server/player/gacha_state.go similarity index 100% rename from go/internal/player/gacha_state.go rename to go/internal/server/player/gacha_state.go diff --git a/go/internal/player/growth.go b/go/internal/server/player/growth.go similarity index 99% rename from go/internal/player/growth.go rename to go/internal/server/player/growth.go index f1f82e0..5e3002f 100644 --- a/go/internal/player/growth.go +++ b/go/internal/server/player/growth.go @@ -7,9 +7,9 @@ import ( "strconv" "sync" - "bd2server/internal/gamedata" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) type characterSnapshot struct { diff --git a/go/internal/player/growth_entries.go b/go/internal/server/player/growth_entries.go similarity index 95% rename from go/internal/player/growth_entries.go rename to go/internal/server/player/growth_entries.go index f2aaf7a..5ff34d9 100644 --- a/go/internal/player/growth_entries.go +++ b/go/internal/server/player/growth_entries.go @@ -7,8 +7,8 @@ import ( "reflect" "strconv" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" ) func loadCharacterEntries(store stateio.AtomicEntryStore, core []byte) (characterSnapshot, []Character, error) { @@ -23,7 +23,7 @@ func loadCharacterEntries(store stateio.AtomicEntryStore, core []byte) (characte if err := json.Unmarshal(core, &saved); err != nil { return characterSnapshot{}, nil, fmt.Errorf("player: decode character core: %w", err) } - if saved.Version != versionconfig.Protocol() || saved.CharacterOrder == nil { + if saved.Version != versionconfig.State() || saved.CharacterOrder == nil { return characterSnapshot{}, nil, errors.New("player: invalid character save version or order") } rows, err := store.ListEntries("characters", "characters") @@ -100,7 +100,7 @@ func (s *CharacterStore) persist(next []Character) error { var core []byte if !s.persistedCore || !reflect.DeepEqual(s.persistedOrder, order) { var err error - core, err = json.Marshal(characterSnapshot{Version: versionconfig.Protocol(), CharacterOrder: order}) + core, err = json.Marshal(characterSnapshot{Version: versionconfig.State(), CharacterOrder: order}) if err != nil { return fmt.Errorf("player: encode character core: %w", err) } diff --git a/go/internal/player/growth_entries_test.go b/go/internal/server/player/growth_entries_test.go similarity index 88% rename from go/internal/player/growth_entries_test.go rename to go/internal/server/player/growth_entries_test.go index 3a81022..3f32567 100644 --- a/go/internal/player/growth_entries_test.go +++ b/go/internal/server/player/growth_entries_test.go @@ -6,12 +6,12 @@ import ( "reflect" "testing" - "bd2server/internal/stateio" + "bd2server/internal/server/stateio" ) func TestCharacterEntriesPreserveOrderAndUpdateOneEntity(t *testing.T) { storage := &collectionWriteSpy{Memory: stateio.NewMemory()} - inventory, err := OpenInventory(storage, &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -69,12 +69,12 @@ func TestCharacterEntriesRejectIncompleteOrLegacyState(t *testing.T) { core string entries map[string][]byte }{ - {"legacy array", `{"version":"2.34.13","characters":[]}`, nil}, - {"duplicate order", `{"version":"2.34.13","character_order":[1,1]}`, map[string][]byte{"1": valid, "2": wrongIndex}}, - {"missing entry", `{"version":"2.34.13","character_order":[1]}`, nil}, - {"extra entry", `{"version":"2.34.13","character_order":[]}`, map[string][]byte{"1": valid}}, - {"mismatched index", `{"version":"2.34.13","character_order":[1]}`, map[string][]byte{"1": wrongIndex}}, - {"noncanonical key", `{"version":"2.34.13","character_order":[1]}`, map[string][]byte{"01": valid}}, + {"legacy array", `{"version":"2.35.10","characters":[]}`, nil}, + {"duplicate order", `{"version":"2.35.10","character_order":[1,1]}`, map[string][]byte{"1": valid, "2": wrongIndex}}, + {"missing entry", `{"version":"2.35.10","character_order":[1]}`, nil}, + {"extra entry", `{"version":"2.35.10","character_order":[]}`, map[string][]byte{"1": valid}}, + {"mismatched index", `{"version":"2.35.10","character_order":[1]}`, map[string][]byte{"1": wrongIndex}}, + {"noncanonical key", `{"version":"2.35.10","character_order":[1]}`, map[string][]byte{"01": valid}}, } { t.Run(test.name, func(t *testing.T) { storage := stateio.NewMemory() @@ -86,7 +86,7 @@ func TestCharacterEntriesRejectIncompleteOrLegacyState(t *testing.T) { t.Fatal(err) } } - inventory, err := OpenInventory(storage, &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -102,7 +102,7 @@ func TestCharacterEntriesRejectOrphansWithoutCore(t *testing.T) { if err := storage.PutEntry("characters", "characters", "1", []byte(`{"inven_index":1,"id":10,"level":1}`)); err != nil { t.Fatal(err) } - inventory, err := OpenInventory(storage, &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/growth_test.go b/go/internal/server/player/growth_test.go similarity index 97% rename from go/internal/player/growth_test.go rename to go/internal/server/player/growth_test.go index 6ae3790..9923c94 100644 --- a/go/internal/player/growth_test.go +++ b/go/internal/server/player/growth_test.go @@ -7,14 +7,14 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func TestCharImmortalReturnsFullOwnedSnapshot(t *testing.T) { dir := t.TempDir() - starter := &Starter{Version: "2.34.13"} + starter := &Starter{Version: "2.35.10"} inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) if err != nil { t.Fatal(err) @@ -55,7 +55,7 @@ func TestCharImmortalReturnsFullOwnedSnapshot(t *testing.T) { func TestCharacterGrowthConsumesMaterialAndPersists(t *testing.T) { dir := t.TempDir() - starter := &Starter{Version: "2.34.13"} + starter := &Starter{Version: "2.35.10"} inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) if err != nil { t.Fatal(err) @@ -126,7 +126,7 @@ func TestCharacterGrowthConsumesMaterialAndPersists(t *testing.T) { func TestGrowthAndImmortalShareDynamicMaximumHealth(t *testing.T) { dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -187,7 +187,7 @@ func TestGrowthAndImmortalShareDynamicMaximumHealth(t *testing.T) { func TestCharacterPromotionUsesExactGameDataCosts(t *testing.T) { dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -251,7 +251,7 @@ func TestCharacterPromotionUsesExactGameDataCosts(t *testing.T) { func TestCharacterGrowthPromotesAndLevelsInOneRequestAcrossStacks(t *testing.T) { dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -340,7 +340,7 @@ func promotionCostsEqual(got, want []gamedata.PromotionCost) bool { func TestCollectionCharacterCombinedGrowthChangesIDWithoutChargingTwice(t *testing.T) { dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -413,7 +413,7 @@ func TestCollectionCharacterCombinedGrowthChangesIDWithoutChargingTwice(t *testi func TestCharacterStoreRejectsSeedDrift(t *testing.T) { dir := t.TempDir() - starter := &Starter{Version: "2.34.13"} + starter := &Starter{Version: "2.35.10"} inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) if err != nil { t.Fatal(err) diff --git a/go/internal/player/inventory_slots.go b/go/internal/server/player/inventory_slots.go similarity index 96% rename from go/internal/player/inventory_slots.go rename to go/internal/server/player/inventory_slots.go index c624af6..9ab41d6 100644 --- a/go/internal/player/inventory_slots.go +++ b/go/internal/server/player/inventory_slots.go @@ -10,10 +10,10 @@ import ( "os" "sync" - "bd2server/internal/gamedata" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) const developmentSettingsVersion = 1 @@ -63,7 +63,7 @@ func OpenInventorySlots(store stateio.Store, design *gamedata.InventorySlotDesig return nil, errors.New("player: incomplete inventory slot configuration") } s := &InventorySlots{store: entries, design: design, wallet: wallet, state: inventorySlotSnapshot{ - Version: versionconfig.Protocol(), InventorySlotCounts: initial, + Version: versionconfig.State(), InventorySlotCounts: initial, }, replies: map[string]inventorySlotReply{}} data, found, err := entries.LoadEntry("items", "slots", "capacity") if err != nil { @@ -84,7 +84,7 @@ func OpenInventorySlots(store stateio.Store, design *gamedata.InventorySlotDesig } func (s *InventorySlots) validateCounts(counts InventorySlotCounts) error { - if s.state.Version != versionconfig.Protocol() || + if s.state.Version != versionconfig.State() || counts.Items < s.design.Items.Default || counts.Items > s.design.Items.Maximum || counts.Storage < s.design.Storage.Default || counts.Storage > s.design.Storage.Maximum || counts.Equipment < s.design.Equipment.Default || counts.Equipment > s.design.Equipment.Maximum || diff --git a/go/internal/player/inventory_slots_test.go b/go/internal/server/player/inventory_slots_test.go similarity index 97% rename from go/internal/player/inventory_slots_test.go rename to go/internal/server/player/inventory_slots_test.go index 213c061..baa3c9e 100644 --- a/go/internal/player/inventory_slots_test.go +++ b/go/internal/server/player/inventory_slots_test.go @@ -5,9 +5,9 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func inventorySlotTestDesign() *gamedata.InventorySlotDesign { diff --git a/go/internal/player/normalized_entries_test.go b/go/internal/server/player/normalized_entries_test.go similarity index 91% rename from go/internal/player/normalized_entries_test.go rename to go/internal/server/player/normalized_entries_test.go index 82140f1..87a99d9 100644 --- a/go/internal/player/normalized_entries_test.go +++ b/go/internal/server/player/normalized_entries_test.go @@ -4,8 +4,8 @@ import ( "bytes" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/stateio" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" ) func TestWalletLedgerUsesEntries(t *testing.T) { @@ -41,7 +41,7 @@ func TestWalletLedgerUsesEntries(t *testing.T) { func TestInventoryEntitiesAndGrantLedgerUseEntries(t *testing.T) { store := stateio.NewMemory() - starter := &Starter{Version: "2.34.13"} + starter := &Starter{Version: "2.35.10"} inventory, err := OpenInventory(store, starter) if err != nil { t.Fatal(err) @@ -100,7 +100,7 @@ func TestEquipmentEntitiesAndGrantLedgerUseEntries(t *testing.T) { func TestEmptyInventoryAndEquipmentEnsureCore(t *testing.T) { store := stateio.NewMemory() - inventory, err := OpenInventory(store, &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -128,9 +128,9 @@ func TestInlineLedgersAndEntitiesRejected(t *testing.T) { core []byte open func(*stateio.Memory) error }{ - {"wallet", []byte(`{"version":"2.34.13","equip_mileage":0,"equip_mileage_exchange_gage":0,"granted":{}}`), func(s *stateio.Memory) error { _, err := OpenWallet(s, Currency{}); return err }}, - {"items", []byte(`{"version":"2.34.13","next_index":900000001,"items":[]}`), func(s *stateio.Memory) error { _, err := OpenInventory(s, &Starter{Version: "2.34.13"}); return err }}, - {"equipment", []byte(`{"version":"2.34.13","next_index":910000001,"equipment":[]}`), func(s *stateio.Memory) error { _, err := OpenEquipmentInventory(s); return err }}, + {"wallet", []byte(`{"version":"2.35.10","equip_mileage":0,"equip_mileage_exchange_gage":0,"granted":{}}`), func(s *stateio.Memory) error { _, err := OpenWallet(s, Currency{}); return err }}, + {"items", []byte(`{"version":"2.35.10","next_index":900000001,"items":[]}`), func(s *stateio.Memory) error { _, err := OpenInventory(s, &Starter{Version: "2.35.10"}); return err }}, + {"equipment", []byte(`{"version":"2.35.10","next_index":910000001,"equipment":[]}`), func(s *stateio.Memory) error { _, err := OpenEquipmentInventory(s); return err }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/go/internal/player/owned.go b/go/internal/server/player/owned.go similarity index 97% rename from go/internal/player/owned.go rename to go/internal/server/player/owned.go index d112994..bf97ee7 100644 --- a/go/internal/player/owned.go +++ b/go/internal/server/player/owned.go @@ -10,10 +10,10 @@ import ( "sync" "time" - "bd2server/internal/gamedata" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) // Inventory holds player-owned rewards separately from the immutable starter @@ -41,7 +41,7 @@ func OpenInventory(store stateio.Store, starter *Starter) (*Inventory, error) { if starter == nil || starter.Validate() != nil || !ok { return nil, errors.New("player: invalid inventory configuration") } - s := &Inventory{store: entries, starter: starter, owned: ownedSnapshot{Version: versionconfig.Protocol(), NextIndex: 900000001, Granted: map[string]bool{}, GrantItems: map[string][]uint64{}}} + s := &Inventory{store: entries, starter: starter, owned: ownedSnapshot{Version: versionconfig.State(), NextIndex: 900000001, Granted: map[string]bool{}, GrantItems: map[string][]uint64{}}} data, err := store.Load("items") if err != nil { return nil, err @@ -66,7 +66,7 @@ func OpenInventory(store stateio.Store, starter *Starter) (*Inventory, error) { } else if err := stateio.RequireNoEntries(entries, "items", "items", "granted", "grant_items"); err != nil { return nil, fmt.Errorf("player: invalid inventory storage: %w", err) } - if s.owned.Version != versionconfig.Protocol() || s.owned.NextIndex < 900000001 { + if s.owned.Version != versionconfig.State() || s.owned.NextIndex < 900000001 { return nil, errors.New("player: invalid saved inventory") } s.owned.Granted, err = loadBoolEntries(entries, "items", "granted") @@ -127,7 +127,7 @@ func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, erro items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items)) items = append(items, s.starter.Items...) items = append(items, s.owned.Items...) - return (&Starter{Version: versionconfig.Protocol(), Items: items}).Handle(path, request) + return (&Starter{Version: versionconfig.State(), Items: items}).Handle(path, request) } // AttachRandomBoxes installs the version-validated deterministic RandomBox diff --git a/go/internal/player/owned_test.go b/go/internal/server/player/owned_test.go similarity index 95% rename from go/internal/player/owned_test.go rename to go/internal/server/player/owned_test.go index 3e63ae0..840bb1a 100644 --- a/go/internal/player/owned_test.go +++ b/go/internal/server/player/owned_test.go @@ -5,13 +5,13 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) func TestBattleRewardPersistsWithoutDuplicate(t *testing.T) { path := filepath.Join(t.TempDir(), "owned.json") - starter := &Starter{Version: "2.34.13"} + starter := &Starter{Version: "2.35.10"} inv, err := OpenInventory(testStore(path), starter) if err != nil { t.Fatal(err) @@ -53,7 +53,7 @@ func TestUseRandomBoxPersistsExactStackAndRewardFromInstalledGameData(t *testing t.Fatal(err) } path := filepath.Join(t.TempDir(), "items.json") - inv, err := OpenInventory(testStore(path), &Starter{Version: "2.34.13"}) + inv, err := OpenInventory(testStore(path), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -89,7 +89,7 @@ func TestUseRandomBoxPersistsExactStackAndRewardFromInstalledGameData(t *testing if len(all) != 1 || all[0].ID != 704 || all[0].Type != 8 || all[0].Count != 100000 { t.Fatalf("UseRandomBox persisted inventory=%+v", all) } - restored, err := OpenInventory(testStore(path), &Starter{Version: "2.34.13"}) + restored, err := OpenInventory(testStore(path), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/preset_runtime.go b/go/internal/server/player/preset_runtime.go similarity index 99% rename from go/internal/player/preset_runtime.go rename to go/internal/server/player/preset_runtime.go index 86e716d..2da202c 100644 --- a/go/internal/player/preset_runtime.go +++ b/go/internal/server/player/preset_runtime.go @@ -5,7 +5,7 @@ import ( "fmt" "sort" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) // PresetEquipmentBinding is one character's complete five-slot equipment diff --git a/go/internal/player/preset_runtime_test.go b/go/internal/server/player/preset_runtime_test.go similarity index 97% rename from go/internal/player/preset_runtime_test.go rename to go/internal/server/player/preset_runtime_test.go index 67b89dd..cf85b9a 100644 --- a/go/internal/player/preset_runtime_test.go +++ b/go/internal/server/player/preset_runtime_test.go @@ -9,7 +9,7 @@ import ( func TestValidatePresetEquipmentDoesNotHoldEquipmentLockDuringCharacterStats(t *testing.T) { dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/starter.go b/go/internal/server/player/starter.go similarity index 98% rename from go/internal/player/starter.go rename to go/internal/server/player/starter.go index da6549e..1e66c14 100644 --- a/go/internal/player/starter.go +++ b/go/internal/server/player/starter.go @@ -10,8 +10,8 @@ import ( "fmt" "os" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) type Item struct { @@ -85,7 +85,7 @@ func Load(path string) (*Starter, error) { } func (s *Starter) Validate() error { - if s == nil || s.Version != versionconfig.Protocol() { + if s == nil || s.Version != versionconfig.State() { return errors.New("player: wrong starter version") } for _, item := range s.Items { diff --git a/go/internal/player/starter_test.go b/go/internal/server/player/starter_test.go similarity index 79% rename from go/internal/player/starter_test.go rename to go/internal/server/player/starter_test.go index ada0bad..afd219a 100644 --- a/go/internal/player/starter_test.go +++ b/go/internal/server/player/starter_test.go @@ -5,12 +5,12 @@ import ( "path/filepath" "testing" - "bd2server/internal/fixture" - "bd2server/internal/wire" + "bd2server/internal/server/fixture" + "bd2server/internal/server/wire" ) func TestStarterCanAnswerWithoutCapture(t *testing.T) { - seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "starter_player.json")) + seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "starter_player.json")) if err != nil { t.Fatal(err) } @@ -26,7 +26,7 @@ func TestStarterCanAnswerWithoutCapture(t *testing.T) { } func TestStarterMatchesInitialAccountSample(t *testing.T) { - root := filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254") + root := filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254") set, err := fixture.Load(root) if err != nil { t.Skipf("optional capture unavailable: %v", err) @@ -43,7 +43,7 @@ func TestStarterMatchesInitialAccountSample(t *testing.T) { if err != nil { t.Fatal(err) } - seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "starter_player.json")) + seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "starter_player.json")) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/store_test.go b/go/internal/server/player/store_test.go similarity index 86% rename from go/internal/player/store_test.go rename to go/internal/server/player/store_test.go index 604a244..0405fec 100644 --- a/go/internal/player/store_test.go +++ b/go/internal/server/player/store_test.go @@ -4,7 +4,7 @@ import ( "path/filepath" "sync" - "bd2server/internal/stateio" + "bd2server/internal/server/stateio" ) var testStores sync.Map diff --git a/go/internal/player/talent_upgrade.go b/go/internal/server/player/talent_upgrade.go similarity index 98% rename from go/internal/player/talent_upgrade.go rename to go/internal/server/player/talent_upgrade.go index 4c299a6..5663a53 100644 --- a/go/internal/player/talent_upgrade.go +++ b/go/internal/server/player/talent_upgrade.go @@ -6,8 +6,8 @@ import ( "math" "strconv" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) const talentSkillUpgradePacketCode = 44 diff --git a/go/internal/player/talent_upgrade_test.go b/go/internal/server/player/talent_upgrade_test.go similarity index 96% rename from go/internal/player/talent_upgrade_test.go rename to go/internal/server/player/talent_upgrade_test.go index 30843dc..c690277 100644 --- a/go/internal/player/talent_upgrade_test.go +++ b/go/internal/server/player/talent_upgrade_test.go @@ -4,8 +4,8 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" - "bd2server/internal/wire" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" ) func testTalentGrowthDesign() *gamedata.TalentGrowthDesign { @@ -33,7 +33,7 @@ func talentUpgradeRequest(seq, character uint64, materials ...Item) []byte { func TestTalentSkillUpgradeConsumesExactCostsPersistsAndReplays(t *testing.T) { dir := t.TempDir() store := testStore(filepath.Join(dir, "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -109,7 +109,7 @@ func TestTalentSkillUpgradeRejectsInvalidStateWithoutCharging(t *testing.T) { t.Run(test.name, func(t *testing.T) { dir := t.TempDir() store := testStore(filepath.Join(dir, "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } @@ -149,7 +149,7 @@ func TestTalentSkillUpgradeRejectsInvalidStateWithoutCharging(t *testing.T) { func TestTalentSkillUpgradePersistsCollectionCharacter(t *testing.T) { dir := t.TempDir() store := testStore(filepath.Join(dir, "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.34.13"}) + inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) } diff --git a/go/internal/player/wallet.go b/go/internal/server/player/wallet.go similarity index 97% rename from go/internal/player/wallet.go rename to go/internal/server/player/wallet.go index f39aed6..84196ff 100644 --- a/go/internal/player/wallet.go +++ b/go/internal/server/player/wallet.go @@ -7,9 +7,9 @@ import ( "math" "sync" - "bd2server/internal/gamedata" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" ) // Currency uses the UserDBInfo currency fields: type 3 is free jewelry and @@ -45,7 +45,7 @@ func OpenWallet(store stateio.Store, initial Currency) (*Wallet, error) { return nil, errors.New("player: wallet store is nil") } s := &Wallet{store: entries, state: walletSnapshot{ - Version: versionconfig.Protocol(), Currency: initial, Granted: map[string]bool{}, Spent: map[string]bool{}, + Version: versionconfig.State(), Currency: initial, Granted: map[string]bool{}, Spent: map[string]bool{}, }} b, err := store.Load("wallet") if err != nil { @@ -65,7 +65,7 @@ func OpenWallet(store stateio.Store, initial Currency) (*Wallet, error) { if _, ok := shape["spent"]; ok { return nil, errors.New("player: wallet spend ledger must use entries") } - if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.Protocol() { + if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() { return nil, errors.New("player: malformed wallet state") } } else if err := stateio.RequireNoEntries(entries, "wallet", "granted", "spent"); err != nil { diff --git a/go/internal/player/wallet_test.go b/go/internal/server/player/wallet_test.go similarity index 95% rename from go/internal/player/wallet_test.go rename to go/internal/server/player/wallet_test.go index 136d0c0..5b101c1 100644 --- a/go/internal/player/wallet_test.go +++ b/go/internal/server/player/wallet_test.go @@ -4,7 +4,7 @@ import ( "path/filepath" "testing" - "bd2server/internal/gamedata" + "bd2server/internal/server/gamedata" ) func TestWalletGrantPersistsAndIsIdempotent(t *testing.T) { diff --git a/go/internal/progress/persistence_test.go b/go/internal/server/progress/persistence_test.go similarity index 98% rename from go/internal/progress/persistence_test.go rename to go/internal/server/progress/persistence_test.go index a073020..6252d1c 100644 --- a/go/internal/progress/persistence_test.go +++ b/go/internal/server/progress/persistence_test.go @@ -4,8 +4,8 @@ import ( "encoding/json" "testing" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func TestStorePersistsProgressAcrossRestart(t *testing.T) { diff --git a/go/internal/progress/progress.go b/go/internal/server/progress/progress.go similarity index 99% rename from go/internal/progress/progress.go rename to go/internal/server/progress/progress.go index f6d2c19..772d545 100644 --- a/go/internal/progress/progress.go +++ b/go/internal/server/progress/progress.go @@ -12,8 +12,8 @@ import ( "strings" "sync" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) var ( diff --git a/go/internal/progress/progress_test.go b/go/internal/server/progress/progress_test.go similarity index 98% rename from go/internal/progress/progress_test.go rename to go/internal/server/progress/progress_test.go index c261236..a5684b3 100644 --- a/go/internal/progress/progress_test.go +++ b/go/internal/server/progress/progress_test.go @@ -6,7 +6,7 @@ import ( "reflect" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestSaveUserPosition(t *testing.T) { diff --git a/go/internal/protocol/protocol.go b/go/internal/server/protocol/protocol.go similarity index 98% rename from go/internal/protocol/protocol.go rename to go/internal/server/protocol/protocol.go index 2310381..add207c 100644 --- a/go/internal/protocol/protocol.go +++ b/go/internal/server/protocol/protocol.go @@ -9,7 +9,7 @@ import ( "fmt" "strings" - "bd2server/internal/cryptox" + "bd2server/internal/server/cryptox" ) type Envelope struct { diff --git a/go/internal/protocol/protocol_test.go b/go/internal/server/protocol/protocol_test.go similarity index 97% rename from go/internal/protocol/protocol_test.go rename to go/internal/server/protocol/protocol_test.go index f13090e..f112f64 100644 --- a/go/internal/protocol/protocol_test.go +++ b/go/internal/server/protocol/protocol_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "bd2server/internal/cryptox" + "bd2server/internal/server/cryptox" ) func TestEncode(t *testing.T) { diff --git a/go/internal/readonly/readonly.go b/go/internal/server/readonly/readonly.go similarity index 96% rename from go/internal/readonly/readonly.go rename to go/internal/server/readonly/readonly.go index 07e088e..80e56da 100644 --- a/go/internal/readonly/readonly.go +++ b/go/internal/server/readonly/readonly.go @@ -12,11 +12,11 @@ import ( "fmt" "os" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) -func ProtocolVersion() string { return versionconfig.Protocol() } +func StateVersion() string { return versionconfig.State() } var ErrInvalidSeed = errors.New("readonly: invalid seed") @@ -113,7 +113,7 @@ func (s *Seed) Write(path string) error { } func (s *Seed) Validate() error { - if s == nil || s.Version != ProtocolVersion() || len(s.Responses) == 0 { + if s == nil || s.Version != StateVersion() || len(s.Responses) == 0 { return ErrInvalidSeed } for path, r := range s.Responses { diff --git a/go/internal/readonly/readonly_test.go b/go/internal/server/readonly/readonly_test.go similarity index 86% rename from go/internal/readonly/readonly_test.go rename to go/internal/server/readonly/readonly_test.go index 379c254..d255d47 100644 --- a/go/internal/readonly/readonly_test.go +++ b/go/internal/server/readonly/readonly_test.go @@ -5,12 +5,12 @@ import ( "path/filepath" "testing" - "bd2server/internal/fixture" - "bd2server/internal/wire" + "bd2server/internal/server/fixture" + "bd2server/internal/server/wire" ) func TestSeedByteCompatibility(t *testing.T) { - root := filepath.Join("..", "..", "..", "data", "capture", "2.34.13", "20260920-003254") + root := filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254") set, err := fixture.Load(root) if err != nil { t.Skipf("optional capture unavailable: %v", err) @@ -19,7 +19,7 @@ func TestSeedByteCompatibility(t *testing.T) { if err != nil { t.Fatal(err) } - seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "readonly.json")) + seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json")) if err != nil { t.Fatal(err) } @@ -57,7 +57,7 @@ func TestSeedByteCompatibility(t *testing.T) { } func TestCashProductEventIndexIsUniqueSemanticSeedFact(t *testing.T) { - seed, err := Load(filepath.Join("..", "..", "seed", "v2_34_13", "readonly.json")) + seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json")) if err != nil { t.Fatal(err) } diff --git a/go/internal/server/resourcefetch/fetch.go b/go/internal/server/resourcefetch/fetch.go new file mode 100644 index 0000000..b3c44d2 --- /dev/null +++ b/go/internal/server/resourcefetch/fetch.go @@ -0,0 +1,341 @@ +// Package resourcefetch mirrors versioned official resources for operators +// that choose a self-hosted CDN. Normal server startup only fetches GameData +// needed by server logic; this package is used by the explicit resources command. +package resourcefetch + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/resourcepolicy" +) + +const catalogPlaceholder = "{BDNetwork.CdnInfo.Info}" + +var catalogHashPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`) +var resourceVersionPattern = regexp.MustCompile(`^[0-9]{14}$`) + +type Options struct { + OutputRoot string + Platform string + BundleVersion string + GameDataVersion string + Client *http.Client + Progress func(string) +} + +type Manifest struct { + BundleVersion string `json:"bundle_version"` + GameDataVersion string `json:"game_data_version"` + Platform string `json:"platform"` + ServerData struct { + CatalogHash string `json:"catalog_hash"` + Bundles int `json:"bundles"` + Bytes int64 `json:"bytes"` + } `json:"server_data"` + GameData gamedata.Result `json:"game_data"` +} + +func Fetch(ctx context.Context, options Options) (Manifest, error) { + if options.OutputRoot == "" || options.BundleVersion == "" || options.GameDataVersion == "" { + return Manifest{}, errors.New("resource fetch: output and resource versions are required") + } + if !resourceVersionPattern.MatchString(options.BundleVersion) || !resourceVersionPattern.MatchString(options.GameDataVersion) { + return Manifest{}, errors.New("resource fetch: resource versions must be 14-digit timestamps") + } + if options.Platform == "" { + options.Platform = "StandaloneWindows64" + } + if strings.ContainsAny(options.Platform, `/\\`) { + return Manifest{}, errors.New("resource fetch: platform must be one path segment") + } + if options.Client == nil { + options.Client = &http.Client{Timeout: 30 * time.Minute} + } + root, err := filepath.Abs(filepath.Clean(options.OutputRoot)) + if err != nil { + return Manifest{}, fmt.Errorf("resource fetch: resolve output: %w", err) + } + if err := os.MkdirAll(root, 0o755); err != nil { + return Manifest{}, fmt.Errorf("resource fetch: create output: %w", err) + } + + gameData, _, err := gamedata.Ensure(ctx, options.Client, filepath.Join(root, "GameData"), options.GameDataVersion, resourcepolicy.OfficialGameDataURL) + if err != nil { + return Manifest{}, err + } + designVersionPath := filepath.Join(root, "GameData", options.GameDataVersion, "design.version") + if info, statErr := os.Stat(designVersionPath); statErr != nil || info.IsDir() || info.Size() == 0 { + designVersion, downloadErr := downloadBytes(ctx, options.Client, resourcepolicy.OfficialGameDataURL+"/"+options.GameDataVersion+"/design.version", 1<<20) + if downloadErr != nil { + return Manifest{}, downloadErr + } + if len(designVersion) == 0 { + return Manifest{}, errors.New("resource fetch: empty GameData design.version") + } + if err := atomicWrite(designVersionPath, designVersion, 0o644); err != nil { + return Manifest{}, err + } + } + options.log(fmt.Sprintf("GameData %s verified (%d entries)", options.GameDataVersion, gameData.EntryCount)) + + hash, bundles, total, err := fetchServerData(ctx, options, root) + if err != nil { + return Manifest{}, err + } + manifest := Manifest{BundleVersion: options.BundleVersion, GameDataVersion: options.GameDataVersion, Platform: options.Platform, GameData: gameData} + manifest.ServerData.CatalogHash = hash + manifest.ServerData.Bundles = bundles + manifest.ServerData.Bytes = total + encoded, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return Manifest{}, err + } + encoded = append(encoded, '\n') + if err := atomicWrite(filepath.Join(root, "resource-fetch-manifest.json"), encoded, 0o644); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func fetchServerData(ctx context.Context, options Options, root string) (string, int, int64, error) { + base := resourcepolicy.OfficialServerDataURL + "/" + options.Platform + "/HD/" + options.BundleVersion + catalog, err := downloadBytes(ctx, options.Client, base+"/catalog_alpha.json", 64<<20) + if err != nil { + return "", 0, 0, err + } + hashBytes, err := downloadBytes(ctx, options.Client, base+"/catalog_alpha.hash", 1024) + if err != nil { + return "", 0, 0, err + } + hash := strings.TrimSpace(string(hashBytes)) + if !catalogHashPattern.MatchString(hash) { + return "", 0, 0, fmt.Errorf("resource fetch: invalid catalog hash %q", hash) + } + paths, err := catalogPaths(catalog) + if err != nil { + return "", 0, 0, err + } + destination := filepath.Join(root, "ServerData", options.Platform, "HD", options.BundleVersion) + if err := atomicWrite(filepath.Join(destination, "catalog_alpha.json"), catalog, 0o644); err != nil { + return "", 0, 0, err + } + if err := atomicWrite(filepath.Join(destination, "catalog_alpha.hash"), append([]byte(strings.ToLower(hash)), '\n'), 0o644); err != nil { + return "", 0, 0, err + } + var total int64 + for index, relative := range paths { + target := filepath.Join(destination, filepath.FromSlash(relative)) + if size, ok := validUnityBundle(target); ok { + total += size + continue + } + remote := base + "/" + escapePath(relative) + size, err := downloadFile(ctx, options.Client, remote, target) + if err != nil { + return "", 0, 0, fmt.Errorf("resource fetch: bundle %q: %w", relative, err) + } + if _, ok := validUnityBundle(target); !ok { + return "", 0, 0, fmt.Errorf("resource fetch: bundle %q lacks UnityFS signature", relative) + } + total += size + if (index+1)%25 == 0 || index+1 == len(paths) { + options.log(fmt.Sprintf("ServerData %d/%d (%.2f GiB)", index+1, len(paths), float64(total)/(1<<30))) + } + } + return strings.ToLower(hash), len(paths), total, nil +} + +func catalogPaths(data []byte) ([]string, error) { + var catalog struct { + InternalIDs []string `json:"m_InternalIds"` + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + if err := decoder.Decode(&catalog); err != nil { + return nil, fmt.Errorf("resource fetch: decode catalog: %w", err) + } + unique := make(map[string]struct{}) + for _, internalID := range catalog.InternalIDs { + if !strings.HasPrefix(internalID, catalogPlaceholder) { + continue + } + parts := strings.Split(strings.ReplaceAll(internalID, "\\", "/"), "/") + if len(parts) < 5 { + continue + } + candidate := strings.TrimLeft(strings.Join(parts[4:], "/"), "/") + clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(candidate))) + if candidate == "" || clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(filepath.FromSlash(candidate)) { + return nil, fmt.Errorf("resource fetch: unsafe catalog path %q", candidate) + } + unique[clean] = struct{}{} + } + if len(unique) == 0 { + return nil, errors.New("resource fetch: catalog contains no resource bundle paths") + } + paths := make([]string, 0, len(unique)) + for path := range unique { + paths = append(paths, path) + } + sort.Strings(paths) + return paths, nil +} + +func downloadBytes(ctx context.Context, client *http.Client, rawURL string, limit int64) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept-Encoding", "identity") + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("resource fetch: GET %s: %w", rawURL, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("resource fetch: GET %s: HTTP %s", rawURL, response.Status) + } + data, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("resource fetch: response exceeds %d bytes", limit) + } + return data, nil +} + +func downloadFile(ctx context.Context, client *http.Client, rawURL, target string) (int64, error) { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return 0, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return 0, err + } + request.Header.Set("Accept-Encoding", "identity") + response, err := client.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return 0, fmt.Errorf("HTTP %s", response.Status) + } + temporary, err := os.CreateTemp(filepath.Dir(target), ".bundle-*.part") + if err != nil { + return 0, err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + written, copyErr := io.Copy(temporary, response.Body) + syncErr := temporary.Sync() + closeErr := temporary.Close() + if copyErr != nil { + return 0, copyErr + } + if syncErr != nil { + return 0, syncErr + } + if closeErr != nil { + return 0, closeErr + } + if written == 0 { + return 0, errors.New("empty response") + } + if err := replaceFile(temporaryPath, target); err != nil { + return 0, err + } + return written, nil +} + +func atomicWrite(target string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(target), ".resource-*.part") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(mode); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return replaceFile(temporaryPath, target) +} + +func replaceFile(source, target string) error { + backup := target + ".replace-backup" + if err := os.Remove(backup); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + moved := false + if err := os.Rename(target, backup); err == nil { + moved = true + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.Rename(source, target); err != nil { + if moved { + _ = os.Rename(backup, target) + } + return err + } + if moved { + _ = os.Remove(backup) + } + return nil +} + +func validUnityBundle(path string) (int64, bool) { + file, err := os.Open(path) + if err != nil { + return 0, false + } + defer file.Close() + var magic [7]byte + if _, err := io.ReadFull(file, magic[:]); err != nil || string(magic[:]) != "UnityFS" { + return 0, false + } + info, err := file.Stat() + return info.Size(), err == nil && info.Size() > 7 +} + +func escapePath(path string) string { + parts := strings.Split(filepath.ToSlash(path), "/") + for index := range parts { + parts[index] = url.PathEscape(parts[index]) + } + return strings.Join(parts, "/") +} + +func (o Options) log(message string) { + if o.Progress != nil { + o.Progress(message) + } +} diff --git a/go/internal/server/resourcefetch/fetch_test.go b/go/internal/server/resourcefetch/fetch_test.go new file mode 100644 index 0000000..165619d --- /dev/null +++ b/go/internal/server/resourcefetch/fetch_test.go @@ -0,0 +1,40 @@ +package resourcefetch + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestCatalogPathsAreUniqueSortedAndSafe(t *testing.T) { + data := []byte(`{"m_InternalIds":["{BDNetwork.CdnInfo.Info}/StandaloneWindows64/HD/1/z.bundle","ignored","{BDNetwork.CdnInfo.Info}\\StandaloneWindows64\\HD\\1\\a.bundle","{BDNetwork.CdnInfo.Info}/StandaloneWindows64/HD/1/z.bundle"]}`) + got, err := catalogPaths(data) + if err != nil { + t.Fatal(err) + } + want := []string{"a.bundle", "z.bundle"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("paths=%v want=%v", got, want) + } + if _, err := catalogPaths([]byte(`{"m_InternalIds":["{BDNetwork.CdnInfo.Info}/StandaloneWindows64/HD/1/../../escape"]}`)); err == nil { + t.Fatal("unsafe catalog path accepted") + } +} + +func TestValidUnityBundle(t *testing.T) { + path := filepath.Join(t.TempDir(), "bundle") + if err := os.WriteFile(path, []byte("UnityFSpayload"), 0o644); err != nil { + t.Fatal(err) + } + if size, ok := validUnityBundle(path); !ok || size != 14 { + t.Fatalf("size=%d ok=%v", size, ok) + } +} + +func TestFetchRejectsUntrustedVersionPathsBeforeNetwork(t *testing.T) { + _, err := Fetch(t.Context(), Options{OutputRoot: t.TempDir(), BundleVersion: "../../escape", GameDataVersion: "20260923193640"}) + if err == nil { + t.Fatal("unsafe resource version accepted") + } +} diff --git a/go/internal/server/resourcepolicy/config.go b/go/internal/server/resourcepolicy/config.go new file mode 100644 index 0000000..b43b0d8 --- /dev/null +++ b/go/internal/server/resourcepolicy/config.go @@ -0,0 +1,155 @@ +// Package resourcepolicy loads the server-authoritative client resource policy. +package resourcepolicy + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "path/filepath" + "strings" +) + +const ( + FileName = "resources.json" + ModeOfficial = "official" + ModeServer = "server" + OfficialServerDataURL = "https://bd2-cdn.akamaized.net/ServerData" + OfficialGameDataURL = "https://bd2-cdn.akamaized.net/GameData" +) + +// Config is private server configuration. It contains no credentials, but it +// remains server-authoritative so a client cannot silently select resources +// that disagree with the server operator's policy. +type Config struct { + Mode string `json:"mode"` + ServerDataURL string `json:"server_data_url,omitempty"` + GameDataURL string `json:"game_data_url,omitempty"` +} + +// Public is the complete resource policy returned to a client. Versions come +// from the server's validated versions.json rather than client input. +type Public struct { + Mode string `json:"mode"` + ServerDataURL string `json:"server_data_url"` + GameDataURL string `json:"game_data_url"` + BundleVersion string `json:"bundle_version"` + GameDataVersion string `json:"game_data_version"` +} + +func Default() Config { + return Config{ + Mode: ModeOfficial, + ServerDataURL: OfficialServerDataURL, + GameDataURL: OfficialGameDataURL, + } +} + +// Load reads one explicit resources.json. Unknown fields and trailing JSON are +// rejected so a misspelled URL key cannot silently fall back to another CDN. +func Load(path string) (Config, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return Config{}, fmt.Errorf("resource policy: read %s: %w", path, err) + } + var cfg Config + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&cfg); err != nil { + return Config{}, fmt.Errorf("resource policy: decode %s: %w", path, err) + } + var trailing any + if err := decoder.Decode(&trailing); err == nil { + return Config{}, fmt.Errorf("resource policy: trailing JSON in %s", path) + } else if !errors.Is(err, io.EOF) { + return Config{}, fmt.Errorf("resource policy: trailing data in %s: %w", path, err) + } + if err := cfg.Validate(); err != nil { + return Config{}, fmt.Errorf("resource policy: %s: %w", path, err) + } + return cfg, nil +} + +// BesideExecutable returns the production configuration location. +func BesideExecutable() (string, error) { + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resource policy: resolve server executable: %w", err) + } + return filepath.Join(filepath.Dir(executable), FileName), nil +} + +func (c Config) Validate() error { + switch c.Mode { + case ModeOfficial: + if c.ServerDataURL == "" { + c.ServerDataURL = OfficialServerDataURL + } + if c.GameDataURL == "" { + c.GameDataURL = OfficialGameDataURL + } + if c.ServerDataURL != OfficialServerDataURL || c.GameDataURL != OfficialGameDataURL { + return errors.New("official mode must use the versioned official resource origins") + } + case ModeServer: + if c.ServerDataURL == "" || c.GameDataURL == "" { + return errors.New("server_data_url and game_data_url are required") + } + default: + return fmt.Errorf("unsupported mode %q", c.Mode) + } + if err := validateBaseURL("server_data_url", c.serverDataURL()); err != nil { + return err + } + return validateBaseURL("game_data_url", c.gameDataURL()) +} + +func (c Config) Public(bundleVersion, gameDataVersion string) Public { + return Public{ + Mode: c.Mode, + ServerDataURL: c.serverDataURL(), + GameDataURL: c.gameDataURL(), + BundleVersion: bundleVersion, + GameDataVersion: gameDataVersion, + } +} + +func (c Config) serverDataURL() string { + if c.Mode == ModeOfficial && c.ServerDataURL == "" { + return OfficialServerDataURL + } + return c.ServerDataURL +} + +func (c Config) gameDataURL() string { + if c.Mode == ModeOfficial && c.GameDataURL == "" { + return OfficialGameDataURL + } + return c.GameDataURL +} + +func validateBaseURL(name, raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || + (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("%s must be an absolute HTTP(S) URL without credentials, query, or fragment", name) + } + if strings.HasSuffix(u.Path, "/") { + return fmt.Errorf("%s must not have a trailing slash", name) + } + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { + return fmt.Errorf("%s must use HTTPS outside loopback development", name) + } + return nil +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/go/internal/server/resourcepolicy/config_test.go b/go/internal/server/resourcepolicy/config_test.go new file mode 100644 index 0000000..154ac57 --- /dev/null +++ b/go/internal/server/resourcepolicy/config_test.go @@ -0,0 +1,63 @@ +package resourcepolicy + +import ( + "os" + "path/filepath" + "testing" +) + +func TestOfficialDefaultsAreCapturedOrigins(t *testing.T) { + cfg := Config{Mode: ModeOfficial} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + public := cfg.Public("bundle", "game-data") + if public.ServerDataURL != OfficialServerDataURL || public.GameDataURL != OfficialGameDataURL { + t.Fatalf("official policy = %+v", public) + } +} + +func TestServerModeRequiresSafeCompleteOrigins(t *testing.T) { + cfg := Config{Mode: ModeServer, ServerDataURL: "https://cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"} + if err := cfg.Validate(); err != nil { + t.Fatalf("server mode rejected: %v", err) + } + bad := []Config{ + {Mode: ModeServer, ServerDataURL: "https://cdn.example.com/ServerData"}, + {Mode: ModeServer, ServerDataURL: "http://cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"}, + {Mode: ModeServer, ServerDataURL: "https://user:password@cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"}, + {Mode: ModeServer, ServerDataURL: "https://cdn.example.com/ServerData/", GameDataURL: "https://cdn.example.com/GameData"}, + {Mode: "self_hosted", ServerDataURL: "https://cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"}, + {Mode: "reverse_proxy", ServerDataURL: "https://cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"}, + {Mode: "local", ServerDataURL: "C:\\bd2-resources\\ServerData", GameDataURL: "C:\\bd2-resources\\GameData"}, + } + for i, cfg := range bad { + if err := cfg.Validate(); err == nil { + t.Fatalf("unsafe policy %d accepted: %+v", i, cfg) + } + } +} + +func TestLoopbackHTTPIsDevelopmentOnly(t *testing.T) { + cfg := Config{Mode: ModeServer, ServerDataURL: "http://127.0.0.1:9000/ServerData", GameDataURL: "http://localhost:9000/GameData"} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestLoadRejectsUnknownAndTrailingJSON(t *testing.T) { + for name, body := range map[string]string{ + "unknown": `{"mode":"official","cdn_url":"https://example.com"}`, + "trailing": `{"mode":"official"}{}`, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), FileName) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("invalid policy accepted") + } + }) + } +} diff --git a/go/internal/schedule/schedule.go b/go/internal/server/schedule/schedule.go similarity index 99% rename from go/internal/schedule/schedule.go rename to go/internal/server/schedule/schedule.go index 7e86ace..597f6e5 100644 --- a/go/internal/schedule/schedule.go +++ b/go/internal/server/schedule/schedule.go @@ -5,7 +5,7 @@ package schedule import ( "errors" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) // Season is the named semantic form of Proto.Net.SeasonInfo. diff --git a/go/internal/schedule/schedule_test.go b/go/internal/server/schedule/schedule_test.go similarity index 97% rename from go/internal/schedule/schedule_test.go rename to go/internal/server/schedule/schedule_test.go index 1e11f9f..1e28356 100644 --- a/go/internal/schedule/schedule_test.go +++ b/go/internal/server/schedule/schedule_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "bd2server/internal/wire" + "bd2server/internal/server/wire" ) func TestVersion23413ContainsRequiredContentSix(t *testing.T) { diff --git a/go/internal/session/server.go b/go/internal/server/session/server.go similarity index 51% rename from go/internal/session/server.go rename to go/internal/server/session/server.go index 13561f9..60781aa 100644 --- a/go/internal/session/server.go +++ b/go/internal/server/session/server.go @@ -4,6 +4,7 @@ package session import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -13,18 +14,28 @@ import ( "sync" "time" - "bd2server/internal/cryptox" - "bd2server/internal/progress" - "bd2server/internal/protocol" - "bd2server/internal/stateio" - "bd2server/internal/transport" - "bd2server/internal/wire" + "bd2server/internal/server/cryptox" + "bd2server/internal/server/progress" + "bd2server/internal/server/protocol" + "bd2server/internal/server/stateio" + "bd2server/internal/server/transport" + "bd2server/internal/server/wire" +) + +const ( + gameSessionTTL = 24 * time.Hour + maxGameSessions = 1024 + maxCookieHeaderLen = 8 << 10 ) type LoginService interface { Login(request, sessionKey []byte) ([]byte, error) } +type LoginAuthenticator interface { + AuthenticateLogin(request []byte) (accountID string, err error) +} + // Handler is implemented by domain services. ok=false means the endpoint is // not owned by that service; unknown endpoints fail closed. type Handler interface { @@ -37,15 +48,37 @@ type SessionAware interface { BeginSession(id string) } +type gameSession struct { + key []byte + accountID string + id string + expiresAt time.Time + lastUsed time.Time +} + type Server struct { - mu sync.Mutex - key []byte - token string - loggedIn bool - login LoginService - handlers []Handler - progress *progress.Store - stateTx stateio.TransactionalStore + mu sync.Mutex + sessions map[[sha256.Size]byte]*gameSession + latestSessionToken [sha256.Size]byte + latestSessionSet bool + login LoginService + handlers []Handler + progress *progress.Store + stateTx stateio.TransactionalStore + auth LoginAuthenticator + now func() time.Time + sessionTTL time.Duration + maxSessions int +} + +func (s *Server) AttachLoginAuthenticator(authenticator LoginAuthenticator) error { + if authenticator == nil { + return errors.New("session login authenticator is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + s.auth = authenticator + return nil } // AttachStateStore wraps each authenticated request (the complete batch for @@ -73,17 +106,10 @@ func NewServerWithProgress(login LoginService, player *progress.Store, handlers if player == nil { return nil, errors.New("session progress store is nil") } - key := make([]byte, 16) - if _, err := rand.Read(key); err != nil { - return nil, fmt.Errorf("create local session key: %w", err) - } - tokenBytes := make([]byte, 24) - if _, err := rand.Read(tokenBytes); err != nil { - return nil, fmt.Errorf("create local session token: %w", err) - } return &Server{ - key: []byte(hex.EncodeToString(key)), token: hex.EncodeToString(tokenBytes) + "|1", - login: login, handlers: append([]Handler(nil), handlers...), progress: player, + sessions: make(map[[sha256.Size]byte]*gameSession), login: login, + handlers: append([]Handler(nil), handlers...), progress: player, + now: time.Now, sessionTTL: gameSessionTTL, maxSessions: maxGameSessions, }, nil } @@ -96,37 +122,55 @@ func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport } } if path == "/LoginUser" { + now := s.now() + s.pruneSessions(now) request, err := cryptox.DecryptBase64Payload(string(body), cryptox.Key()) if err != nil { return transport.RawReply{}, fmt.Errorf("LoginUser decrypt: %w", err) } - proto, err := s.login.Login(request, s.key) - if err != nil { - return transport.RawReply{}, fmt.Errorf("LoginUser: %w", err) - } - sessionBytes := make([]byte, 12) - if _, err := rand.Read(sessionBytes); err != nil { - return transport.RawReply{}, fmt.Errorf("create login request identity: %w", err) - } - sessionID := hex.EncodeToString(sessionBytes) - for _, handler := range s.handlers { - if aware, ok := handler.(SessionAware); ok { - aware.BeginSession(sessionID) + accountID := "local-owner" + if s.auth != nil { + accountID, err = s.auth.AuthenticateLogin(request) + if err != nil { + return transport.RawReply{}, fmt.Errorf("LoginUser authentication: %w", err) + } + if accountID == "" { + return transport.RawReply{}, errors.New("LoginUser authentication returned an empty account ID") } } - s.loggedIn = true - encoded, err := protocol.Encode(3, proto, cryptox.Key(), time.Now().UnixMilli()) - return transport.RawReply{Body: encoded, Cookie: s.token}, err + game, token, err := newGameSession(accountID, now, s.sessionTTL) + if err != nil { + return transport.RawReply{}, err + } + proto, err := s.login.Login(request, game.key) + if err != nil { + clear(game.key) + return transport.RawReply{}, fmt.Errorf("LoginUser: %w", err) + } + encoded, err := protocol.Encode(3, proto, cryptox.Key(), now.UnixMilli()) + if err != nil { + clear(game.key) + return transport.RawReply{}, err + } + s.makeSessionRoom() + tokenKey := sessionTokenKey(token) + s.sessions[tokenKey] = game + s.latestSessionToken = tokenKey + s.latestSessionSet = true + s.activate(game) + return transport.RawReply{Body: encoded, Cookie: token}, nil } - if err := s.authorize(cookie); err != nil { + game, err := s.authorize(cookie) + if err != nil { return transport.RawReply{}, err } + s.activate(game) if path == "/BatchRequest" { return s.withStateTransaction(func() (transport.RawReply, error) { - return s.handleBatch(body) + return s.handleBatch(body, game.key) }) } - request, err := cryptox.DecryptBase64Payload(string(body), s.key) + request, err := cryptox.DecryptBase64Payload(string(body), game.key) if err != nil { return transport.RawReply{}, fmt.Errorf("%s decrypt: %w", path, err) } @@ -135,11 +179,79 @@ func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport if err != nil { return transport.RawReply{}, err } - encoded, err := protocol.Encode(code, response, s.key, time.Now().UnixMilli()) + encoded, err := protocol.Encode(code, response, game.key, s.now().UnixMilli()) return transport.RawReply{Body: encoded}, err }) } +func newGameSession(accountID string, now time.Time, ttl time.Duration) (*gameSession, string, error) { + keyBytes := make([]byte, 16) + if _, err := rand.Read(keyBytes); err != nil { + return nil, "", fmt.Errorf("create game session key: %w", err) + } + tokenBytes := make([]byte, 24) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, "", fmt.Errorf("create game session token: %w", err) + } + idBytes := make([]byte, 12) + if _, err := rand.Read(idBytes); err != nil { + return nil, "", fmt.Errorf("create login request identity: %w", err) + } + return &gameSession{ + key: []byte(hex.EncodeToString(keyBytes)), + accountID: accountID, + id: hex.EncodeToString(idBytes), + expiresAt: now.Add(ttl), + lastUsed: now, + }, hex.EncodeToString(tokenBytes) + "|1", nil +} + +func sessionTokenKey(token string) [sha256.Size]byte { return sha256.Sum256([]byte(token)) } + +func (s *Server) pruneSessions(now time.Time) { + for token, game := range s.sessions { + if !now.Before(game.expiresAt) { + s.deleteSession(token, game) + } + } +} + +func (s *Server) makeSessionRoom() { + for len(s.sessions) >= s.maxSessions { + var oldestToken [sha256.Size]byte + var oldest *gameSession + for token, game := range s.sessions { + if oldest == nil || game.lastUsed.Before(oldest.lastUsed) { + oldestToken, oldest = token, game + } + } + if oldest == nil { + return + } + s.deleteSession(oldestToken, oldest) + } +} + +func (s *Server) deleteSession(token [sha256.Size]byte, game *gameSession) { + delete(s.sessions, token) + for i := range game.key { + game.key[i] = 0 + } + game.accountID = "" + game.id = "" + if s.latestSessionSet && token == s.latestSessionToken { + s.latestSessionSet = false + } +} + +func (s *Server) activate(game *gameSession) { + for _, handler := range s.handlers { + if aware, ok := handler.(SessionAware); ok { + aware.BeginSession(game.id) + } + } +} + func (s *Server) withStateTransaction(run func() (transport.RawReply, error)) (reply transport.RawReply, err error) { if s.stateTx == nil { return run() @@ -178,9 +290,9 @@ func (s *Server) withStateTransaction(run func() (transport.RawReply, error)) (r return reply, nil } -func (s *Server) handleBatch(body []byte) (transport.RawReply, error) { +func (s *Server) handleBatch(body, key []byte) (transport.RawReply, error) { batchStarted := time.Now() - requests, decoded, err := protocol.DecodeBatchRequest(body, s.key) + requests, decoded, err := protocol.DecodeBatchRequest(body, key) if err != nil { return transport.RawReply{}, err } @@ -197,7 +309,7 @@ func (s *Server) handleBatch(body []byte) (transport.RawReply, error) { } else if itemElapsed >= 100*time.Millisecond { slog.Warn("slow batch item", "index", i, "path", request.Path, "duration_ms", float64(itemElapsed.Microseconds())/1000) } - raw, err := protocol.Encode(code, response, s.key, time.Now().UnixMilli()) + raw, err := protocol.Encode(code, response, key, time.Now().UnixMilli()) if err != nil { return transport.RawReply{}, err } @@ -257,18 +369,71 @@ func (s *Server) dispatch(path string, request []byte) (int, []byte, error) { return 0, nil, fmt.Errorf("%w: %s", transport.ErrNotImplemented, path) } -func (s *Server) authorize(cookie string) error { - if !s.loggedIn { - return errors.New("session login required") +func (s *Server) authorize(cookie string) (*gameSession, error) { + token, err := parseSessionCookie(cookie) + if err != nil { + return nil, err } - want := "s=" + s.token - for _, value := range strings.Split(cookie, ";") { - if strings.TrimSpace(value) == want { - return nil - } + now := s.now() + s.pruneSessions(now) + game, ok := s.sessions[sessionTokenKey(token)] + if !ok { + return nil, errors.New("invalid game session cookie") } - return errors.New("invalid local session cookie") + game.lastUsed = now + return game, nil +} + +func parseSessionCookie(cookie string) (string, error) { + if cookie == "" { + return "", errors.New("session login required") + } + if len(cookie) > maxCookieHeaderLen { + return "", errors.New("game session cookie header is too large") + } + var token string + seen := false + for _, value := range strings.Split(cookie, ";") { + name, candidate, found := strings.Cut(strings.TrimSpace(value), "=") + if !found || name != "s" { + continue + } + if seen { + return "", errors.New("duplicate game session cookie") + } + seen = true + token = candidate + } + if !seen { + return "", errors.New("session login required") + } + if len(token) != 50 || token[48:] != "|1" { + return "", errors.New("invalid game session cookie") + } + for _, char := range token[:48] { + if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') { + return "", errors.New("invalid game session cookie") + } + } + return token, nil +} + +// KeyForTest returns the most recently created session key when called without +// a token. Supplying a raw cookie value selects that client's isolated key. +func (s *Server) KeyForTest(token ...string) []byte { + s.mu.Lock() + defer s.mu.Unlock() + selected := s.latestSessionToken + if len(token) != 0 { + selected = sessionTokenKey(token[0]) + } else if !s.latestSessionSet { + return nil + } + game := s.sessions[selected] + if game == nil { + return nil + } + return append([]byte(nil), game.key...) } -func (s *Server) KeyForTest() []byte { return append([]byte(nil), s.key...) } func (s *Server) ProgressForTest() *progress.Store { return s.progress } diff --git a/go/internal/session/server_test.go b/go/internal/server/session/server_test.go similarity index 54% rename from go/internal/session/server_test.go rename to go/internal/server/session/server_test.go index e20393a..ec0a77c 100644 --- a/go/internal/session/server_test.go +++ b/go/internal/server/session/server_test.go @@ -5,14 +5,16 @@ import ( "encoding/json" "errors" "path/filepath" + "strings" "testing" + "time" - "bd2server/internal/accountstate" - "bd2server/internal/cryptox" - "bd2server/internal/protocol" - "bd2server/internal/stateio" - "bd2server/internal/transport" - "bd2server/internal/wire" + "bd2server/internal/server/accountstate" + "bd2server/internal/server/cryptox" + "bd2server/internal/server/protocol" + "bd2server/internal/server/stateio" + "bd2server/internal/server/transport" + "bd2server/internal/server/wire" ) type fakeLogin struct{} @@ -25,6 +27,19 @@ func (fakeLogin) Login(request, key []byte) ([]byte, error) { return wire.AppendBytes(nil, 1, user), nil } +type fakeAuthenticator struct { + accountID string + err error + calls int + request []byte +} + +func (a *fakeAuthenticator) AuthenticateLogin(request []byte) (string, error) { + a.calls++ + a.request = append([]byte(nil), request...) + return a.accountID, a.err +} + type fakeDomain struct{} type fakeStateGate struct{ err error } @@ -132,6 +147,11 @@ func (fakeDomain) Handle(path string, request []byte) (int, []byte, bool, error) func login(t *testing.T, server *Server) transport.RawReply { t.Helper() request := wire.AppendVarint(nil, 1, 1) + return loginRequest(t, server, request) +} + +func loginRequest(t *testing.T, server *Server, request []byte) transport.RawReply { + t.Helper() body, err := cryptox.EncryptBase64Payload(request, cryptox.Key()) if err != nil { t.Fatal(err) @@ -151,6 +171,209 @@ func login(t *testing.T, server *Server) transport.RawReply { return reply } +func TestLoginAuthenticatesBeforeCreatingGameSession(t *testing.T) { + server, err := NewServer(fakeLogin{}) + if err != nil { + t.Fatal(err) + } + authenticator := &fakeAuthenticator{accountID: "account-1"} + if err := server.AttachLoginAuthenticator(authenticator); err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendString(request, 2, "short-lived-access-token") + reply := loginRequest(t, server, request) + if authenticator.calls != 1 { + t.Fatalf("AuthenticateLogin calls=%d, want 1", authenticator.calls) + } + accessToken, found, err := wire.Bytes(authenticator.request, 2) + if err != nil || !found || string(accessToken) != "short-lived-access-token" { + t.Fatalf("authenticated access token=%q found=%v err=%v", accessToken, found, err) + } + game := server.sessions[sessionTokenKey(reply.Cookie)] + if game == nil || game.accountID != "account-1" { + t.Fatalf("game session=%+v, want authenticated account", game) + } +} + +func TestLoginAuthenticationFailureCreatesNoSession(t *testing.T) { + server, err := NewServer(fakeLogin{}) + if err != nil { + t.Fatal(err) + } + authenticator := &fakeAuthenticator{err: errors.New("invalid access token")} + if err := server.AttachLoginAuthenticator(authenticator); err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 1, 1) + body, err := cryptox.EncryptBase64Payload(request, cryptox.Key()) + if err != nil { + t.Fatal(err) + } + if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil { + t.Fatal("invalid access token was accepted") + } + if authenticator.calls != 1 || len(server.sessions) != 0 { + t.Fatalf("auth calls=%d sessions=%d", authenticator.calls, len(server.sessions)) + } + + authenticator.err = nil + authenticator.accountID = "" + if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil { + t.Fatal("empty authenticated account ID was accepted") + } + if len(server.sessions) != 0 { + t.Fatalf("empty account ID created %d sessions", len(server.sessions)) + } +} + +func TestGameSessionsHaveIndependentKeysAndCookies(t *testing.T) { + server, err := NewServer(fakeLogin{}, fakeDomain{}) + if err != nil { + t.Fatal(err) + } + first := login(t, server) + firstKey := server.KeyForTest(first.Cookie) + second := login(t, server) + secondKey := server.KeyForTest(second.Cookie) + if first.Cookie == second.Cookie { + t.Fatal("two logins received the same game session cookie") + } + if string(firstKey) == string(secondKey) { + t.Fatal("two logins received the same game session key") + } + + request := wire.AppendVarint(nil, 1, 2) + firstBody, err := cryptox.EncryptBase64Payload(request, firstKey) + if err != nil { + t.Fatal(err) + } + firstReply, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "other=value; s="+first.Cookie) + if err != nil { + t.Fatalf("first session stopped working after second login: %v", err) + } + var firstEnvelope protocol.Envelope + if err := json.Unmarshal(firstReply.Body, &firstEnvelope); err != nil { + t.Fatal(err) + } + if _, err := cryptox.DecryptBase64Payload(firstEnvelope.Data, firstKey); err != nil { + t.Fatalf("first response did not use first key: %v", err) + } + + secondBody, err := cryptox.EncryptBase64Payload(request, secondKey) + if err != nil { + t.Fatal(err) + } + if _, err := server.DispatchRaw("/EmptyInfo", []byte(secondBody), "s="+second.Cookie); err != nil { + t.Fatalf("second session request failed: %v", err) + } + if _, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "s="+second.Cookie); err == nil { + t.Fatal("request encrypted with first key was accepted under second cookie") + } + if _, err := server.DispatchRaw("/EmptyInfo", []byte(secondBody), "s="+first.Cookie); err == nil { + t.Fatal("request encrypted with second key was accepted under first cookie") + } + if _, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "s="+first.Cookie+"; s="+second.Cookie); err == nil { + t.Fatal("ambiguous duplicate session cookies were accepted") + } +} + +func TestGameSessionExpiresAndClearsKey(t *testing.T) { + server, err := NewServer(fakeLogin{}, fakeDomain{}) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) + server.now = func() time.Time { return now } + server.sessionTTL = time.Minute + reply := login(t, server) + game := server.sessions[sessionTokenKey(reply.Cookie)] + key := append([]byte(nil), game.key...) + now = now.Add(time.Minute) + request := wire.AppendVarint(nil, 1, 2) + body, err := cryptox.EncryptBase64Payload(request, key) + if err != nil { + t.Fatal(err) + } + if _, err := server.DispatchRaw("/EmptyInfo", []byte(body), "s="+reply.Cookie); err == nil { + t.Fatal("expired game session was accepted") + } + if len(server.sessions) != 0 { + t.Fatalf("expired game session remains in map: %d", len(server.sessions)) + } + for i, value := range game.key { + if value != 0 { + t.Fatalf("expired session key byte %d was not cleared", i) + } + } +} + +func TestGameSessionLimitEvictsLeastRecentlyUsedAndClearsKey(t *testing.T) { + server, err := NewServer(fakeLogin{}, fakeDomain{}) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) + server.now = func() time.Time { return now } + server.maxSessions = 2 + first := login(t, server) + firstKey := server.KeyForTest(first.Cookie) + now = now.Add(time.Second) + second := login(t, server) + secondGame := server.sessions[sessionTokenKey(second.Cookie)] + + now = now.Add(time.Second) + request := wire.AppendVarint(nil, 1, 2) + firstBody, err := cryptox.EncryptBase64Payload(request, firstKey) + if err != nil { + t.Fatal(err) + } + if _, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "s="+first.Cookie); err != nil { + t.Fatal(err) + } + now = now.Add(time.Second) + third := login(t, server) + if len(server.sessions) != 2 { + t.Fatalf("game sessions=%d, want cap 2", len(server.sessions)) + } + if _, ok := server.sessions[sessionTokenKey(first.Cookie)]; !ok { + t.Fatal("recently used first session was evicted") + } + if _, ok := server.sessions[sessionTokenKey(third.Cookie)]; !ok { + t.Fatal("new third session is missing") + } + if _, ok := server.sessions[sessionTokenKey(second.Cookie)]; ok { + t.Fatal("least recently used second session was not evicted") + } + for i, value := range secondGame.key { + if value != 0 { + t.Fatalf("evicted session key byte %d was not cleared", i) + } + } +} + +func TestSessionCookieParsingIsStrict(t *testing.T) { + valid := strings.Repeat("a", 48) + "|1" + if token, err := parseSessionCookie("other=value; s=" + valid); err != nil || token != valid { + t.Fatalf("valid cookie token=%q err=%v", token, err) + } + for name, cookie := range map[string]string{ + "missing": "other=value", + "empty": "s=", + "wrong length": "s=abcd|1", + "uppercase": "s=" + strings.Repeat("A", 48) + "|1", + "wrong version": "s=" + strings.Repeat("a", 48) + "|2", + "duplicate": "s=" + valid + "; s=" + valid, + "oversized": "x=" + strings.Repeat("a", maxCookieHeaderLen), + } { + t.Run(name, func(t *testing.T) { + if _, err := parseSessionCookie(cookie); err == nil { + t.Fatalf("accepted malformed cookie %q", cookie) + } + }) + } +} + func TestNativeLoginAndBatch(t *testing.T) { server, err := NewServer(fakeLogin{}, fakeDomain{}) if err != nil { diff --git a/go/internal/stateio/json.go b/go/internal/server/stateio/json.go similarity index 100% rename from go/internal/stateio/json.go rename to go/internal/server/stateio/json.go diff --git a/go/internal/stateio/json_test.go b/go/internal/server/stateio/json_test.go similarity index 100% rename from go/internal/stateio/json_test.go rename to go/internal/server/stateio/json_test.go diff --git a/go/internal/stateio/stateio.go b/go/internal/server/stateio/stateio.go similarity index 100% rename from go/internal/stateio/stateio.go rename to go/internal/server/stateio/stateio.go diff --git a/go/internal/transport/http.go b/go/internal/server/transport/http.go similarity index 64% rename from go/internal/transport/http.go rename to go/internal/server/transport/http.go index ba7bea4..f71ddda 100644 --- a/go/internal/transport/http.go +++ b/go/internal/server/transport/http.go @@ -12,7 +12,9 @@ import ( "strings" "time" - "bd2server/internal/bootstrap" + "bd2server/internal/server/authconfig" + "bd2server/internal/server/bootstrap" + "bd2server/internal/server/resourcepolicy" ) type Envelope struct { @@ -81,25 +83,25 @@ func (b Bootstrap) now() time.Time { } type HTTP struct { - Dispatcher Dispatcher - Raw RawDispatcher - Logger *slog.Logger - Now func() time.Time - CDNDir string - GameDataDir string + Dispatcher Dispatcher + Raw RawDispatcher + Logger *slog.Logger + Now func() time.Time + Authentication authconfig.Config + AuthenticationHandler http.Handler + ResourcePolicy resourcepolicy.Public } func (h HTTP) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/StateCheckInfoJson", h.stateCheck) mux.HandleFunc("/game/StateCheckInfoJson", h.stateCheck) + mux.HandleFunc("/auth/config", h.authenticationConfig) + mux.HandleFunc("/client/resources", h.clientResources) + if h.AuthenticationHandler != nil { + mux.Handle("/auth/", h.AuthenticationHandler) + } mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) - if h.CDNDir != "" { - mux.Handle("/assets/ServerData/", http.StripPrefix("/assets/ServerData/", http.FileServer(http.Dir(h.CDNDir)))) - } - if h.GameDataDir != "" { - mux.Handle("/assets/GameData/", http.StripPrefix("/assets/GameData/", http.FileServer(http.Dir(h.GameDataDir)))) - } mux.HandleFunc("/game/", h.game) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") @@ -108,6 +110,64 @@ func (h HTTP) Handler() http.Handler { return mux } +func (h HTTP) clientResources(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "PUT required", http.StatusMethodNotAllowed) + return + } + defer r.Body.Close() + body, err := io.ReadAll(io.LimitReader(r.Body, 16<<10+1)) + if err != nil || len(body) > 16<<10 { + http.Error(w, "resource selection too large", http.StatusRequestEntityTooLarge) + return + } + decoder := json.NewDecoder(strings.NewReader(string(body))) + decoder.DisallowUnknownFields() + var request struct { + CDNMode string `json:"cdn_mode"` + } + if err := decoder.Decode(&request); err != nil { + http.Error(w, "invalid resource selection", http.StatusBadRequest) + return + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + http.Error(w, "invalid resource selection", http.StatusBadRequest) + return + } + if request.CDNMode != resourcepolicy.ModeServer { + http.Error(w, "cdn_mode must be server", http.StatusBadRequest) + return + } + if h.ResourcePolicy.Mode == "" || h.ResourcePolicy.ServerDataURL == "" || + h.ResourcePolicy.GameDataURL == "" || h.ResourcePolicy.BundleVersion == "" || + h.ResourcePolicy.GameDataVersion == "" { + http.Error(w, "resource policy unavailable", http.StatusServiceUnavailable) + return + } + if request.CDNMode != h.ResourcePolicy.Mode { + http.Error(w, "requested CDN mode is not enabled by this server", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(h.ResourcePolicy) +} + +func (h HTTP) authenticationConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET required", http.StatusMethodNotAllowed) + return + } + if err := h.Authentication.Validate(); err != nil { + http.Error(w, "authentication policy unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(h.Authentication.Public()) +} + func (h HTTP) stateCheck(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST required", http.StatusMethodNotAllowed) @@ -152,7 +212,10 @@ func (h HTTP) game(w http.ResponseWriter, r *http.Request) { return } if reply.Cookie != "" { - http.SetCookie(w, &http.Cookie{Name: "s", Value: reply.Cookie, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{ + Name: "s", Value: reply.Cookie, Path: "/game/", HttpOnly: true, + Secure: h.secureCookies(), SameSite: http.SameSiteLaxMode, + }) } if reply.ContentType == "" { reply.ContentType = "application/json; charset=utf-8" @@ -182,7 +245,10 @@ func (h HTTP) game(w http.ResponseWriter, r *http.Request) { return } if reply.Cookie != "" { - http.SetCookie(w, &http.Cookie{Name: "s", Value: reply.Cookie, Path: "/game/", HttpOnly: true, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{ + Name: "s", Value: reply.Cookie, Path: "/game/", HttpOnly: true, + Secure: h.secureCookies(), SameSite: http.SameSiteLaxMode, + }) } answer := Envelope{PacketCode: reply.PacketCode, Data: base64.StdEncoding.EncodeToString(reply.Data), ServerNowTime: h.now().UnixMilli(), Notify: reply.Notify} w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -208,3 +274,8 @@ func (h HTTP) logger() *slog.Logger { } return slog.Default() } + +func (h HTTP) secureCookies() bool { + return strings.EqualFold(h.Authentication.Mode, "oauth") && + strings.HasPrefix(strings.ToLower(h.Authentication.PublicURL), "https://") +} diff --git a/go/internal/server/transport/http_test.go b/go/internal/server/transport/http_test.go new file mode 100644 index 0000000..df0b622 --- /dev/null +++ b/go/internal/server/transport/http_test.go @@ -0,0 +1,168 @@ +package transport + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "bd2server/internal/server/authconfig" + "bd2server/internal/server/bootstrap" + "bd2server/internal/server/resourcepolicy" + "bd2server/internal/server/wire" +) + +func TestBootstrapRoundTrip(t *testing.T) { + cfg := bootstrap.Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: "test-client", BundleVer: "test-bundle"} + now := func() time.Time { return time.UnixMilli(12345) } + h := HTTP{Dispatcher: Bootstrap{Config: cfg, Now: now}, Now: now}.Handler() + for _, path := range []string{"/MaintenanceInfo", "/ServerInfo", "/NoticeInfo", "/ServerNowTime"} { + request := base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2)) + req := httptest.NewRequest(http.MethodPut, "/game"+path, strings.NewReader(request)) + res := httptest.NewRecorder() + h.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("%s: %d %s", path, res.Code, res.Body.String()) + } + var envelope Envelope + if err := json.Unmarshal(res.Body.Bytes(), &envelope); err != nil || envelope.ErrorType != 0 || envelope.ServerNowTime != 12345 { + t.Fatalf("%s: %v %+v", path, err, envelope) + } + if _, err := base64.StdEncoding.DecodeString(envelope.Data); err != nil { + t.Fatalf("%s: %v", path, err) + } + } +} + +func TestAuthenticationConfig(t *testing.T) { + h := HTTP{Authentication: authconfig.Config{ + Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER", + Providers: map[string]authconfig.ProviderConfig{ + "discord": {ClientID: "d", ClientSecretEnv: "DS"}, + "google": {ClientID: "g", ClientSecretEnv: "GS"}, + }, + }}.Handler() + res := httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/auth/config", nil)) + if res.Code != http.StatusOK || res.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d cache=%q body=%q", res.Code, res.Header().Get("Cache-Control"), res.Body.String()) + } + var config authconfig.Public + if err := json.Unmarshal(res.Body.Bytes(), &config); err != nil || config.Mode != "oauth" || len(config.Providers) != 2 { + t.Fatalf("config=%+v err=%v", config, err) + } + + res = httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodPost, "/auth/config", nil)) + if res.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST status=%d", res.Code) + } +} + +type cookieRawDispatcher struct{} + +func (cookieRawDispatcher) DispatchRaw(string, []byte, string) (RawReply, error) { + return RawReply{Body: []byte(`{}`), Cookie: "0123456789abcdef0123456789abcdef0123456789abcdef|1"}, nil +} + +func TestOAuthGameSessionCookieIsHostOnlySecureAndGameScoped(t *testing.T) { + h := HTTP{ + Raw: cookieRawDispatcher{}, + Authentication: authconfig.Config{ + Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER", + Providers: map[string]authconfig.ProviderConfig{ + "discord": {ClientID: "d", ClientSecretEnv: "DS"}, + }, + }, + }.Handler() + response := httptest.NewRecorder() + h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/LoginUser", strings.NewReader("request"))) + cookies := response.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("cookies=%v", cookies) + } + cookie := cookies[0] + if cookie.Name != "s" || cookie.Path != "/game/" || !cookie.HttpOnly || !cookie.Secure || + cookie.SameSite != http.SameSiteLaxMode || cookie.Domain != "" { + t.Fatalf("unsafe game session cookie: %+v", cookie) + } +} + +func TestUnknownPacketFailsClosed(t *testing.T) { + h := HTTP{Dispatcher: Bootstrap{}}.Handler() + res := httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/game/InventedPacket", strings.NewReader("AA=="))) + if res.Code != http.StatusNotImplemented { + t.Fatalf("unknown packet status: %d", res.Code) + } +} + +func TestClientResourcePolicyUsesPUTAndDoesNotCache(t *testing.T) { + policy := resourcepolicy.Public{ + Mode: resourcepolicy.ModeServer, ServerDataURL: "https://cdn.example.com/ServerData", + GameDataURL: "https://cdn.example.com/GameData", BundleVersion: "bundle", GameDataVersion: "game-data", + } + h := HTTP{ResourcePolicy: policy}.Handler() + res := httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/client/resources", strings.NewReader(`{"cdn_mode":"server"}`))) + if res.Code != http.StatusOK || res.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d cache=%q body=%q", res.Code, res.Header().Get("Cache-Control"), res.Body.String()) + } + var got resourcepolicy.Public + if err := json.Unmarshal(res.Body.Bytes(), &got); err != nil || got != policy { + t.Fatalf("policy=%+v err=%v", got, err) + } + + res = httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/client/resources", nil)) + if res.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET status=%d", res.Code) + } +} + +func TestClientResourcePolicyRejectsInvalidOrMismatchedSelection(t *testing.T) { + policy := resourcepolicy.Public{ + Mode: resourcepolicy.ModeServer, ServerDataURL: "https://cdn.example.com/ServerData", + GameDataURL: "https://cdn.example.com/GameData", BundleVersion: "bundle", GameDataVersion: "game-data", + } + h := HTTP{ResourcePolicy: policy}.Handler() + for name, test := range map[string]struct { + body string + status int + }{ + "missing": {`{}`, http.StatusBadRequest}, + "unknown": {`{"cdn_mode":"server","url":"https://evil.example"}`, http.StatusBadRequest}, + "trailing": {`{"cdn_mode":"server"}{}`, http.StatusBadRequest}, + "official": {`{"cdn_mode":"official"}`, http.StatusBadRequest}, + "local": {`{"cdn_mode":"local"}`, http.StatusBadRequest}, + "legacy-self-hosted": {`{"cdn_mode":"self_hosted"}`, http.StatusBadRequest}, + "legacy-reverse-proxy": {`{"cdn_mode":"reverse_proxy"}`, http.StatusBadRequest}, + "too-large": {`{"cdn_mode":"server","padding":"` + strings.Repeat("x", 16<<10) + `"}`, http.StatusRequestEntityTooLarge}, + } { + t.Run(name, func(t *testing.T) { + res := httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/client/resources", strings.NewReader(test.body))) + if res.Code != test.status { + t.Fatalf("status=%d want=%d body=%q", res.Code, test.status, res.Body.String()) + } + }) + } +} + +func TestClientResourcePolicyRejectsServerSelectionWhenServerUsesOfficialResources(t *testing.T) { + policy := resourcepolicy.Public{ + Mode: resourcepolicy.ModeOfficial, ServerDataURL: resourcepolicy.OfficialServerDataURL, + GameDataURL: resourcepolicy.OfficialGameDataURL, BundleVersion: "bundle", GameDataVersion: "game-data", + } + res := httptest.NewRecorder() + HTTP{ResourcePolicy: policy}.Handler().ServeHTTP( + res, + httptest.NewRequest(http.MethodPut, "/client/resources", strings.NewReader(`{"cdn_mode":"server"}`)), + ) + if res.Code != http.StatusConflict { + t.Fatalf("status=%d want=%d body=%q", res.Code, http.StatusConflict, res.Body.String()) + } +} diff --git a/go/internal/versionconfig/config.go b/go/internal/server/versionconfig/config.go similarity index 95% rename from go/internal/versionconfig/config.go rename to go/internal/server/versionconfig/config.go index fafb901..21a3049 100644 --- a/go/internal/versionconfig/config.go +++ b/go/internal/server/versionconfig/config.go @@ -1,4 +1,4 @@ -// Package versionconfig loads the repository-wide client, protocol, and +// Package versionconfig loads the repository-wide client, seed, and // resource version selection. The same versions.json also drives plugin builds. package versionconfig @@ -27,7 +27,6 @@ var ( // SourcePath is populated by Load and is not part of the JSON document. type Config struct { ClientVersion string `json:"client_version"` - ProtocolVersion string `json:"protocol_version"` GameDataVersion string `json:"game_data_version"` BundleVersion string `json:"bundle_version"` SeedDirectory string `json:"seed_directory"` @@ -38,6 +37,7 @@ type Config struct { type PluginVersions struct { LocalIdentity string `json:"local_identity"` CaptureEnvironment string `json:"capture_environment"` + LoginUI string `json:"login_ui"` } // Load reads one explicit version file. Unknown fields and trailing JSON are @@ -71,15 +71,16 @@ func Load(path string) (Config, error) { } func Client() string { return Current().ClientVersion } -func Protocol() string { return Current().ProtocolVersion } +func State() string { return Current().ClientVersion } func GameData() string { return Current().GameDataVersion } func Bundle() string { return Current().BundleVersion } func (c Config) Validate() error { for name, value := range map[string]string{ - "client_version": c.ClientVersion, "protocol_version": c.ProtocolVersion, + "client_version": c.ClientVersion, "plugins.local_identity": c.Plugins.LocalIdentity, "plugins.capture_environment": c.Plugins.CaptureEnvironment, + "plugins.login_ui": c.Plugins.LoginUI, } { if !semanticVersion.MatchString(value) { return fmt.Errorf("%s must be a numeric three-part version", name) diff --git a/go/internal/versionconfig/config_test.go b/go/internal/server/versionconfig/config_test.go similarity index 78% rename from go/internal/versionconfig/config_test.go rename to go/internal/server/versionconfig/config_test.go index da04131..00d4b7d 100644 --- a/go/internal/versionconfig/config_test.go +++ b/go/internal/server/versionconfig/config_test.go @@ -11,11 +11,10 @@ func TestLoadAndResolve(t *testing.T) { path := filepath.Join(dir, FileName) data := []byte(`{ "client_version":"2.35.10", - "protocol_version":"2.34.13", "game_data_version":"20260923193640", "bundle_version":"20260921135230", - "seed_directory":"go/seed/v2_34_13", - "plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0"} + "seed_directory":"go/seed/v2_35_10", + "plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"} }`) if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatal(err) @@ -24,10 +23,10 @@ func TestLoadAndResolve(t *testing.T) { if err != nil { t.Fatal(err) } - if cfg.ClientVersion != "2.35.10" || cfg.ProtocolVersion != "2.34.13" { + if cfg.ClientVersion != "2.35.10" || cfg.Plugins.LoginUI != "0.1.0" { t.Fatalf("unexpected config: %+v", cfg) } - want := filepath.Join(dir, "go", "seed", "v2_34_13") + want := filepath.Join(dir, "go", "seed", "v2_35_10") if got := cfg.Resolve(cfg.SeedDirectory); got != want { t.Fatalf("Resolve()=%q, want %q", got, want) } @@ -36,7 +35,7 @@ func TestLoadAndResolve(t *testing.T) { func TestLoadRejectsUnknownAndEscapingFields(t *testing.T) { for name, body := range map[string]string{ "unknown": `{"client_version":"2.35.10","unknown":true}`, - "escape": `{"client_version":"2.35.10","protocol_version":"2.34.13","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"../seed","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0"}}`, + "escape": `{"client_version":"2.35.10","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"../seed","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`, } { t.Run(name, func(t *testing.T) { path := filepath.Join(t.TempDir(), FileName) @@ -55,11 +54,10 @@ func TestFindUsesEnvironmentOverride(t *testing.T) { path := filepath.Join(dir, FileName) data := []byte(`{ "client_version":"9.8.7", - "protocol_version":"6.5.4", "game_data_version":"20260102030405", "bundle_version":"20260504030201", "seed_directory":"seed/current", - "plugins":{"local_identity":"1.2.3","capture_environment":"4.5.6"} + "plugins":{"local_identity":"1.2.3","capture_environment":"4.5.6","login_ui":"7.8.9"} }`) if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatal(err) diff --git a/go/internal/wire/wire.go b/go/internal/server/wire/wire.go similarity index 100% rename from go/internal/wire/wire.go rename to go/internal/server/wire/wire.go diff --git a/go/internal/wire/wire_test.go b/go/internal/server/wire/wire_test.go similarity index 100% rename from go/internal/wire/wire_test.go rename to go/internal/server/wire/wire_test.go diff --git a/go/internal/world/world.go b/go/internal/server/world/world.go similarity index 98% rename from go/internal/world/world.go rename to go/internal/server/world/world.go index c8d44a2..916c4f7 100644 --- a/go/internal/world/world.go +++ b/go/internal/server/world/world.go @@ -13,13 +13,13 @@ import ( "sort" "sync" - "bd2server/internal/deck" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/progress" - "bd2server/internal/stateio" - "bd2server/internal/versionconfig" - "bd2server/internal/wire" + "bd2server/internal/server/deck" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/progress" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" ) var ErrInvalidRequest = errors.New("world: invalid request") @@ -48,7 +48,7 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store, if err := json.Unmarshal(b, &seed); err != nil { return nil, fmt.Errorf("world: decode seed: %w", err) } - if seed.Version != versionconfig.Protocol() || seed.PackID <= 0 || seed.StartQuestID <= 0 || seed.BattleUnlockQuestID <= 0 || seed.RewardCharacter.ID == 0 || seed.RewardCostume.ID == 0 || len(seed.StoryCharacters) == 0 { + if seed.Version != versionconfig.State() || seed.PackID <= 0 || seed.StartQuestID <= 0 || seed.BattleUnlockQuestID <= 0 || seed.RewardCharacter.ID == 0 || seed.RewardCostume.ID == 0 || len(seed.StoryCharacters) == 0 { return nil, errors.New("world: invalid seed") } ownedCharacters := append([]player.Character(nil), starter.Characters...) diff --git a/go/internal/world/world_test.go b/go/internal/server/world/world_test.go similarity index 91% rename from go/internal/world/world_test.go rename to go/internal/server/world/world_test.go index 00702c5..1e93a7d 100644 --- a/go/internal/world/world_test.go +++ b/go/internal/server/world/world_test.go @@ -5,16 +5,16 @@ import ( "encoding/binary" "testing" - "bd2server/internal/deck" - "bd2server/internal/gamedata" - "bd2server/internal/player" - "bd2server/internal/progress" - "bd2server/internal/stateio" - "bd2server/internal/wire" + "bd2server/internal/server/deck" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/progress" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" ) func testService() *Service { - return &Service{seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, state: progress.NewStore(), starter: &player.Starter{Version: "2.34.13"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}} + return &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: progress.NewStore(), starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}} } func TestQuest29EchoesCurrentStoryDeck(t *testing.T) { @@ -28,7 +28,7 @@ func TestQuest29EchoesCurrentStoryDeck(t *testing.T) { } } } - decks, err := deck.NewStore(deck.Seed{Version: "2.34.13", FieldDeck: []deck.FieldEntry{ + decks, err := deck.NewStore(deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{ {Slot: 1, CharacterInvenIndex: 535607162}, }}) if err != nil { @@ -45,8 +45,8 @@ func TestQuest29EchoesCurrentStoryDeck(t *testing.T) { if _, _, ok, err := decks.Handle("/DeckSave", save); err != nil || !ok { t.Fatalf("deck save ok=%v err=%v", ok, err) } - s := &Service{seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, state: state, - starter: &player.Starter{Version: "2.34.13"}, quests: quests, decks: decks} + s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, + starter: &player.Starter{Version: "2.35.10"}, quests: quests, decks: decks} request := wire.AppendVarint(nil, 1, 2) request = wire.AppendVarint(request, 2, 29) request = wire.AppendVarint(request, 3, 21) @@ -158,9 +158,9 @@ func TestFinalQuestClearIncludesEmptyNextQuestInfo(t *testing.T) { // non-null QuestDBInfo with ID 0, satisfying the client's final-quest // sentinel path. Exact official final-pack wire parity is still unverified. s := &Service{ - seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 38}, + seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 38}, state: progress.NewStore(), - starter: &player.Starter{Version: "2.34.13"}, + starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{38: {ID: 38}}, transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}, } @@ -206,7 +206,7 @@ func TestPackInfoRestoresCompletedPackAndUnlockedNextPack(t *testing.T) { t.Fatal(err) } } - s := &Service{seed: Seed{Version: "2.34.13", PackID: 21}, state: state, + s := &Service{seed: Seed{Version: "2.35.10", PackID: 21}, state: state, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}}, transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}} code, response, handled, err := s.Handle("/PackInfo", wire.AppendVarint(nil, 1, 1)) if err != nil || !handled || code != 4 { @@ -238,9 +238,9 @@ func TestPack22InitializesWithIndependentQuestIdentity(t *testing.T) { pack21 := map[int]gamedata.QuestDesign{1: {ID: 1}} pack22 := map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}} s := &Service{ - seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, + seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, - starter: &player.Starter{Version: "2.34.13"}, + starter: &player.Starter{Version: "2.35.10"}, quests: pack21, packs: map[int]map[int]gamedata.QuestDesign{21: pack21, 22: pack22}, transitions: map[int]gamedata.PackTransition{ @@ -302,7 +302,7 @@ func TestQuest28GrantsEquipmentInRewardBundle(t *testing.T) { if err != nil { t.Fatal(err) } - starter := &player.Starter{Version: "2.34.13"} + starter := &player.Starter{Version: "2.35.10"} inventory, err := player.OpenInventory(storage, starter) if err != nil { t.Fatal(err) @@ -310,7 +310,7 @@ func TestQuest28GrantsEquipmentInRewardBundle(t *testing.T) { entry := quests[28] entry.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}, {Type: 10, ID: 10010}} quests[28] = entry - s := &Service{seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, state: state, + s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, starter: starter, equipment: equipment, inventory: inventory, quests: quests} request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 28) @@ -356,8 +356,8 @@ func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) { if err != nil { t.Fatal(err) } - s := &Service{seed: Seed{Version: "2.34.13", PackID: 21, StartQuestID: 1}, state: state, - starter: &player.Starter{Version: "2.34.13"}, wallet: wallet, quests: quests} + s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, + starter: &player.Starter{Version: "2.35.10"}, wallet: wallet, quests: quests} request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 27) request = wire.AppendVarint(request, 3, 21) @@ -379,7 +379,7 @@ func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) { func TestPackInfoUsesPersistedCharacterLevel(t *testing.T) { storage := stateio.NewMemory() - starter := &player.Starter{Version: "2.34.13"} + starter := &player.Starter{Version: "2.35.10"} inventory, err := player.OpenInventory(storage, starter) if err != nil { t.Fatal(err) diff --git a/go/internal/transport/http_test.go b/go/internal/transport/http_test.go deleted file mode 100644 index ef9116a..0000000 --- a/go/internal/transport/http_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package transport - -import ( - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - "time" - - "bd2server/internal/bootstrap" - "bd2server/internal/wire" -) - -func TestBootstrapRoundTrip(t *testing.T) { - cfg := bootstrap.Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: "test-client", BundleVer: "test-bundle"} - now := func() time.Time { return time.UnixMilli(12345) } - h := HTTP{Dispatcher: Bootstrap{Config: cfg, Now: now}, Now: now}.Handler() - for _, path := range []string{"/MaintenanceInfo", "/ServerInfo", "/NoticeInfo", "/ServerNowTime"} { - request := base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2)) - req := httptest.NewRequest(http.MethodPut, "/game"+path, strings.NewReader(request)) - res := httptest.NewRecorder() - h.ServeHTTP(res, req) - if res.Code != http.StatusOK { - t.Fatalf("%s: %d %s", path, res.Code, res.Body.String()) - } - var envelope Envelope - if err := json.Unmarshal(res.Body.Bytes(), &envelope); err != nil || envelope.ErrorType != 0 || envelope.ServerNowTime != 12345 { - t.Fatalf("%s: %v %+v", path, err, envelope) - } - if _, err := base64.StdEncoding.DecodeString(envelope.Data); err != nil { - t.Fatalf("%s: %v", path, err) - } - } -} - -func TestUnknownPacketFailsClosed(t *testing.T) { - h := HTTP{Dispatcher: Bootstrap{}}.Handler() - res := httptest.NewRecorder() - h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/game/InventedPacket", strings.NewReader("AA=="))) - if res.Code != http.StatusNotImplemented { - t.Fatalf("unknown packet status: %d", res.Code) - } -} - -func TestGameDataStaticFiles(t *testing.T) { - dir := t.TempDir() - path := dir + "/123/release" - if err := os.MkdirAll(path, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path+"/common-dbdata.info", []byte("42"), 0o644); err != nil { - t.Fatal(err) - } - h := HTTP{GameDataDir: dir}.Handler() - res := httptest.NewRecorder() - h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/assets/GameData/123/release/common-dbdata.info", nil)) - if res.Code != http.StatusOK || res.Body.String() != "42" { - t.Fatalf("static GameData: status=%d body=%q", res.Code, res.Body.String()) - } -} diff --git a/go/seed/v2_34_13/decks.json b/go/seed/v2_35_10/decks.json similarity index 95% rename from go/seed/v2_34_13/decks.json rename to go/seed/v2_35_10/decks.json index a2396da..2f03506 100644 --- a/go/seed/v2_34_13/decks.json +++ b/go/seed/v2_35_10/decks.json @@ -1,5 +1,5 @@ { - "version": "2.34.13", + "version": "2.35.10", "field_deck": [ {"slot": 1, "character_inven_index": 535604120, "costume_inven_index": 609336981}, {"slot": 2, "character_inven_index": 535604119, "costume_inven_index": 609336980}, diff --git a/go/seed/v2_34_13/gacha_schedule.json b/go/seed/v2_35_10/gacha_schedule.json similarity index 100% rename from go/seed/v2_34_13/gacha_schedule.json rename to go/seed/v2_35_10/gacha_schedule.json diff --git a/go/seed/v2_34_13/login_user.json b/go/seed/v2_35_10/login_user.json similarity index 93% rename from go/seed/v2_34_13/login_user.json rename to go/seed/v2_35_10/login_user.json index 4a06f6b..b53f56d 100644 --- a/go/seed/v2_34_13/login_user.json +++ b/go/seed/v2_35_10/login_user.json @@ -1,5 +1,5 @@ { - "version": "2.34.13", + "version": "2.35.10", "packet_code": 3, "user_info_base64": "CJqO1A4SDkd1ZXN0XzMwNzM4MjAyKGQwZDiQTlD0A2gBeGTgAQXqAQIQAeoBDAgCEAEaBjEwLDEuNeoBBAgDEAHqAQQIBBAB6gEKCAUQARoEMzAsM+oBBwgGEAEaATXqAQcICBABGgEw6gEECAkQAeoBBAgKEAH4AYDLqrEEgAIDiAIFsAKQ5vPTizS4AgHYAtKFj9SLNKADBagDBcgDBfADCvgDHoAEHogEyAE=", "response_fields_base64": "IgBQAVgBYAFoAQ==" diff --git a/go/seed/v2_34_13/mail.json b/go/seed/v2_35_10/mail.json similarity index 98% rename from go/seed/v2_34_13/mail.json rename to go/seed/v2_35_10/mail.json index af5b4d4..019088e 100644 --- a/go/seed/v2_34_13/mail.json +++ b/go/seed/v2_35_10/mail.json @@ -1,5 +1,5 @@ { - "version": "2.34.13", + "version": "2.35.10", "mails": [ { "mail_id": 13042978139, diff --git a/go/seed/v2_34_13/readonly.json b/go/seed/v2_35_10/readonly.json similarity index 99% rename from go/seed/v2_34_13/readonly.json rename to go/seed/v2_35_10/readonly.json index da86f55..b8f3e0c 100644 --- a/go/seed/v2_34_13/readonly.json +++ b/go/seed/v2_35_10/readonly.json @@ -1,5 +1,5 @@ { - "version": "2.34.13", + "version": "2.35.10", "responses": { "/CashShopInfo": { "packet_code": 60, diff --git a/go/seed/v2_34_13/starter_player.json b/go/seed/v2_35_10/starter_player.json similarity index 99% rename from go/seed/v2_34_13/starter_player.json rename to go/seed/v2_35_10/starter_player.json index 1c24d4c..6c47469 100644 --- a/go/seed/v2_34_13/starter_player.json +++ b/go/seed/v2_35_10/starter_player.json @@ -1,5 +1,5 @@ { - "version": "2.34.13", + "version": "2.35.10", "items": [ { "inven_index": 853258187, diff --git a/go/seed/v2_34_13/world.json b/go/seed/v2_35_10/world.json similarity index 97% rename from go/seed/v2_34_13/world.json rename to go/seed/v2_35_10/world.json index 8a0fe2a..a621c8d 100644 --- a/go/seed/v2_34_13/world.json +++ b/go/seed/v2_35_10/world.json @@ -1,5 +1,5 @@ { - "version": "2.34.13", + "version": "2.35.10", "pack_id": 21, "start_quest_id": 1, "battle_unlock_quest_id": 26, diff --git a/plugins/Generate-VersionSource.ps1 b/plugins/Generate-VersionSource.ps1 index 43df468..82d99be 100644 --- a/plugins/Generate-VersionSource.ps1 +++ b/plugins/Generate-VersionSource.ps1 @@ -2,7 +2,7 @@ param( [Parameter(Mandatory)] [string]$Config, [Parameter(Mandatory)] [string]$Output, - [Parameter(Mandatory)] [ValidateSet('local_identity', 'capture_environment')] [string]$Plugin + [Parameter(Mandatory)] [ValidateSet('local_identity', 'capture_environment', 'login_ui')] [string]$Plugin ) $ErrorActionPreference = 'Stop' @@ -12,7 +12,6 @@ $semver = '^[0-9]+\.[0-9]+\.[0-9]+$' $resource = '^[0-9]{14}$' foreach ($entry in @( @('client_version', $versions.client_version, $semver), - @('protocol_version', $versions.protocol_version, $semver), @('game_data_version', $versions.game_data_version, $resource), @('bundle_version', $versions.bundle_version, $resource), @("plugins.$Plugin", $pluginVersion, $semver) @@ -29,7 +28,6 @@ namespace Bd2Build internal static class Versions { internal const string Client = "$($versions.client_version)"; - internal const string Protocol = "$($versions.protocol_version)"; internal const string GameData = "$($versions.game_data_version)"; internal const string Bundle = "$($versions.bundle_version)"; internal const string Plugin = "$pluginVersion"; diff --git a/plugins/LocalIdentity/ClientRouting.cs b/plugins/LocalIdentity/ClientRouting.cs new file mode 100644 index 0000000..b6bb841 --- /dev/null +++ b/plugins/LocalIdentity/ClientRouting.cs @@ -0,0 +1,399 @@ +using System; +using System.IO; +using System.Net; +using System.Text; +using BepInEx; +using BepInEx.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Bd2LocalIdentity; + +internal sealed class ClientRouting : IDisposable +{ + private const string ConfigFileName = "bd2.client.json"; + private const string OfficialServerData = "https://bd2-cdn.akamaized.net/ServerData"; + private const string OfficialGameData = "https://bd2-cdn.akamaized.net/GameData"; + private const int MaximumResponseBytes = 16 * 1024; + + private readonly ManualLogSource log; + private readonly ClientConfig config; + private readonly ResourcePolicy resources; + private readonly LocalResourceServer localResourceServer; + + private ClientRouting( + ManualLogSource log, + ClientConfig config, + ResourcePolicy resources, + LocalResourceServer localResourceServer) + { + this.log = log; + this.config = config; + this.resources = resources; + this.localResourceServer = localResourceServer; + } + + internal static ClientRouting Load(ManualLogSource log) + { + string path = Path.Combine(Paths.ConfigPath, ConfigFileName); + ClientConfig config = ParseConfig(ReadBoundedFile(path, MaximumResponseBytes)); + ValidateOrigin(config.server_origin, "server_origin"); + config.server_origin = config.server_origin.TrimEnd('/'); + + ResourcePolicy resources; + LocalResourceServer localResourceServer = null; + try + { + if (config.cdn_mode == "official") + { + resources = new ResourcePolicy + { + mode = "official", + server_data_url = OfficialServerData, + game_data_url = OfficialGameData, + bundle_version = Bd2Build.Versions.Bundle, + game_data_version = Bd2Build.Versions.GameData + }; + log.LogInfo("Client resource mode official: direct official CDN with release-locked versions"); + } + else if (config.cdn_mode == "local") + { + localResourceServer = LocalResourceServer.Start( + log, + config.local_resource_directory, + Bd2Build.Versions.Bundle, + Bd2Build.Versions.GameData); + resources = new ResourcePolicy + { + mode = "local", + server_data_url = localResourceServer.Origin + "/ServerData", + game_data_url = localResourceServer.Origin + "/GameData", + bundle_version = Bd2Build.Versions.Bundle, + game_data_version = Bd2Build.Versions.GameData + }; + log.LogInfo("Client resource mode local: serving downloaded resources from " + + localResourceServer.RootDirectory); + } + else + { + resources = RequestResourcePolicy(log, config); + } + ValidatePolicy(config.cdn_mode, resources); + return new ClientRouting(log, config, resources, localResourceServer); + } + catch + { + localResourceServer?.Dispose(); + throw; + } + } + + public void Dispose() + { + localResourceServer?.Dispose(); + } + + internal void ApplyServerOrigin() + { + Type serverURLInfo = Plugin.FindType("ὫὡὩὤὣὨὯὭὥὠὩ") ?? Plugin.FindType("BDNetwork.ServerURLInfo"); + var setUriDirectly = serverURLInfo?.GetMethod( + "ὬὠὢὮὪὮὭὫὮὡὥ", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public, + null, + new[] { typeof(Uri) }, + null) ?? serverURLInfo?.GetMethod( + "SetUriDirectly", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public, + null, + new[] { typeof(Uri) }, + null); + if (setUriDirectly == null) + { + throw new MissingMethodException("BDNetwork.ServerURLInfo.SetUriDirectly(Uri) was not found"); + } + Uri gameEndpoint = new Uri(new Uri(config.server_origin + "/"), "game/"); + setUriDirectly.Invoke(null, new object[] { gameEndpoint }); + log.LogInfo("Client server origin applied: " + config.server_origin); + } + + internal void ApplyResources() + { + Type cdnInfo = Plugin.FindType("BDNetwork.CdnInfo"); + if (cdnInfo == null) + { + throw new TypeLoadException("BDNetwork.CdnInfo was not found"); + } + SetStaticString(cdnInfo, "Info", resources.server_data_url); + SetStaticString(cdnInfo, "Version", resources.bundle_version); + SetStaticString(cdnInfo, "SoundVersion", resources.bundle_version); + SetStaticString(cdnInfo, "GameDataInfo", resources.game_data_url); + SetStaticString(cdnInfo, "GameDataVersion", resources.game_data_version); + log.LogInfo("Client resources applied: mode=" + resources.mode + + " server_data=" + resources.server_data_url + + " game_data=" + resources.game_data_url + + " bundle=" + resources.bundle_version + + " game_data_version=" + resources.game_data_version); + } + + private static ResourcePolicy RequestResourcePolicy(ManualLogSource log, ClientConfig config) + { + Uri endpoint = new Uri(new Uri(config.server_origin + "/"), "client/resources"); + byte[] body = Encoding.UTF8.GetBytes("{\"cdn_mode\":\"" + config.cdn_mode + "\"}"); + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(endpoint); + request.Method = "PUT"; + request.ContentType = "application/json"; + request.Accept = "application/json"; + request.ContentLength = body.Length; + request.AllowAutoRedirect = false; + request.Timeout = 10_000; + request.ReadWriteTimeout = 10_000; + if (endpoint.IsLoopback) + { + // UnityWebRequest can inherit a system proxy even for loopback. + // The configured private server must be contacted directly. + request.Proxy = null; + } + + log.LogInfo("Requesting client resource policy: origin=" + config.server_origin + + " endpoint=" + endpoint.AbsoluteUri + " mode=" + config.cdn_mode + " method=PUT"); + try + { + using (Stream stream = request.GetRequestStream()) + { + stream.Write(body, 0, body.Length); + } + using HttpWebResponse response = (HttpWebResponse)request.GetResponse(); + log.LogInfo("Client resource policy response: origin=" + config.server_origin + + " status=" + (int)response.StatusCode); + if (response.StatusCode != HttpStatusCode.OK) + { + throw new InvalidDataException("resource policy returned HTTP " + (int)response.StatusCode); + } + using Stream responseStream = response.GetResponseStream(); + return ParsePolicy(ReadBoundedStream(responseStream, MaximumResponseBytes)); + } + catch (WebException ex) + { + int status = ex.Response is HttpWebResponse response ? (int)response.StatusCode : 0; + ex.Response?.Close(); + log.LogError("Client resource policy failed: origin=" + config.server_origin + + " status=" + status + " network_status=" + ex.Status); + throw new InvalidOperationException( + "Could not load " + config.cdn_mode + " resource policy from " + endpoint.AbsoluteUri, + ex); + } + } + + private static ClientConfig ParseConfig(string json) + { + JObject value = ParseObject(json, "client configuration"); + RequireOnly(value, "schema_version", "server_origin", "cdn_mode", "local_resource_directory"); + ClientConfig config = new ClientConfig + { + schema_version = RequiredInteger(value, "schema_version"), + server_origin = RequiredString(value, "server_origin"), + cdn_mode = RequiredString(value, "cdn_mode"), + local_resource_directory = OptionalString(value, "local_resource_directory") + }; + if (config.schema_version != 2) + { + throw new InvalidDataException("bd2.client.json schema_version must be 2"); + } + if (config.cdn_mode != "official" && config.cdn_mode != "local" && config.cdn_mode != "server") + { + throw new InvalidDataException("bd2.client.json cdn_mode must be official, local, or server"); + } + if (config.cdn_mode == "local" && string.IsNullOrWhiteSpace(config.local_resource_directory)) + { + throw new InvalidDataException("local_resource_directory is required when cdn_mode is local"); + } + if (config.cdn_mode != "local" && !string.IsNullOrWhiteSpace(config.local_resource_directory)) + { + throw new InvalidDataException("local_resource_directory is only valid when cdn_mode is local"); + } + return config; + } + + private static ResourcePolicy ParsePolicy(string json) + { + JObject value = ParseObject(json, "resource policy"); + RequireOnly(value, "mode", "server_data_url", "game_data_url", "bundle_version", "game_data_version"); + return new ResourcePolicy + { + mode = RequiredString(value, "mode"), + server_data_url = RequiredString(value, "server_data_url"), + game_data_url = RequiredString(value, "game_data_url"), + bundle_version = RequiredString(value, "bundle_version"), + game_data_version = RequiredString(value, "game_data_version") + }; + } + + private static JObject ParseObject(string json, string description) + { + using StringReader input = new StringReader(json); + using JsonTextReader reader = new JsonTextReader(input) + { + DateParseHandling = DateParseHandling.None + }; + JObject result; + try + { + result = JObject.Load(reader, new JsonLoadSettings + { + DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error + }); + } + catch (JsonException ex) + { + throw new InvalidDataException("Invalid " + description + " JSON", ex); + } + if (reader.Read()) + { + throw new InvalidDataException(description + " contains trailing JSON"); + } + return result; + } + + private static void ValidatePolicy(string requestedMode, ResourcePolicy policy) + { + if (policy.mode != requestedMode) + { + throw new InvalidDataException("resource policy mode " + policy.mode + " does not match requested mode " + requestedMode); + } + ValidateResourceURL(policy.server_data_url, "server_data_url"); + ValidateResourceURL(policy.game_data_url, "game_data_url"); + if (policy.bundle_version != Bd2Build.Versions.Bundle) + { + throw new InvalidDataException("resource policy bundle_version does not match this client release"); + } + if (policy.game_data_version != Bd2Build.Versions.GameData) + { + throw new InvalidDataException("resource policy game_data_version does not match this client release"); + } + policy.server_data_url = policy.server_data_url.TrimEnd('/'); + policy.game_data_url = policy.game_data_url.TrimEnd('/'); + } + + private static void ValidateOrigin(string raw, string name) + { + if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri uri) || + !string.IsNullOrEmpty(uri.UserInfo) || uri.AbsolutePath != "/" || + !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || + (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback))) + { + throw new InvalidDataException(name + " must be an HTTPS origin, except that loopback may use HTTP"); + } + } + + private static void ValidateResourceURL(string raw, string name) + { + if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri uri) || + !string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || + (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback))) + { + throw new InvalidDataException(name + " must be HTTPS, except that loopback may use HTTP"); + } + } + + private static string ReadBoundedFile(string path, int maximumBytes) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException("Client routing configuration is missing", path); + } + using FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + return ReadBoundedStream(stream, maximumBytes); + } + + private static string ReadBoundedStream(Stream stream, int maximumBytes) + { + using MemoryStream output = new MemoryStream(); + byte[] buffer = new byte[4096]; + while (true) + { + int read = stream.Read(buffer, 0, Math.Min(buffer.Length, maximumBytes + 1 - (int)output.Length)); + if (read == 0) + { + break; + } + output.Write(buffer, 0, read); + if (output.Length > maximumBytes) + { + throw new InvalidDataException("JSON document exceeds " + maximumBytes + " bytes"); + } + } + return new UTF8Encoding(false, true).GetString(output.ToArray()); + } + + private static void RequireOnly(JObject value, params string[] names) + { + foreach (JProperty property in value.Properties()) + { + if (Array.IndexOf(names, property.Name) < 0) + { + throw new InvalidDataException("unknown JSON field " + property.Name); + } + } + } + + private static string RequiredString(JObject value, string name) + { + JToken token = value[name]; + if (token == null || token.Type != JTokenType.String || string.IsNullOrEmpty((string)token)) + { + throw new InvalidDataException(name + " must be a non-empty string"); + } + return (string)token; + } + + private static int RequiredInteger(JObject value, string name) + { + JToken token = value[name]; + if (token == null || token.Type != JTokenType.Integer) + { + throw new InvalidDataException(name + " must be an integer"); + } + return (int)token; + } + + private static string OptionalString(JObject value, string name) + { + JToken token = value[name]; + if (token == null || token.Type == JTokenType.Null) + { + return string.Empty; + } + if (token.Type != JTokenType.String) + { + throw new InvalidDataException(name + " must be a string when present"); + } + return (string)token; + } + + private static void SetStaticString(Type type, string propertyName, string value) + { + var property = type.GetProperty(propertyName, System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); + if (property?.PropertyType != typeof(string) || property.GetSetMethod() == null) + { + throw new MissingMemberException(type.FullName + "." + propertyName + " was not found"); + } + property.SetValue(null, value, null); + } + + private sealed class ClientConfig + { + public int schema_version; + public string server_origin; + public string cdn_mode; + public string local_resource_directory; + } + + private sealed class ResourcePolicy + { + public string mode; + public string server_data_url; + public string game_data_url; + public string bundle_version; + public string game_data_version; + } +} diff --git a/plugins/LocalIdentity/LocalIdentity.csproj b/plugins/LocalIdentity/LocalIdentity.csproj index cf56426..d8c3444 100644 --- a/plugins/LocalIdentity/LocalIdentity.csproj +++ b/plugins/LocalIdentity/LocalIdentity.csproj @@ -6,25 +6,31 @@ BD2LocalIdentity Bd2LocalIdentity local_identity + $(GameDir)\BrownDust II_Data\Managed + $(GameDir)\BepInEx - $(GameDir)\BepInEx\core\BepInEx.dll + $(BD2BepInExDir)\core\BepInEx.dll false - $(GameDir)\BepInEx\core\0Harmony.dll + $(BD2BepInExDir)\core\0Harmony.dll false - $(GameDir)\BrownDust II_Data\Managed\UnityEngine.CoreModule.dll + $(BD2ManagedDir)\UnityEngine.CoreModule.dll false - $(GameDir)\BrownDust II_Data\Managed\UnityEngine.dll + $(BD2ManagedDir)\UnityEngine.dll + false + + + $(BD2ManagedDir)\Newtonsoft.Json.dll false diff --git a/plugins/LocalIdentity/LocalResourceServer.cs b/plugins/LocalIdentity/LocalResourceServer.cs new file mode 100644 index 0000000..88fc39d --- /dev/null +++ b/plugins/LocalIdentity/LocalResourceServer.cs @@ -0,0 +1,435 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using BepInEx.Logging; + +namespace Bd2LocalIdentity; + +internal sealed class LocalResourceServer : IDisposable +{ + private const int MaximumRequestHeaderBytes = 16 * 1024; + private const int MaximumConcurrentRequests = 16; + + private readonly ManualLogSource log; + private readonly TcpListener listener; + private readonly Thread acceptThread; + private readonly Semaphore requestSlots = new Semaphore(MaximumConcurrentRequests, MaximumConcurrentRequests); + private volatile bool disposed; + + private LocalResourceServer(ManualLogSource log, string rootDirectory) + { + this.log = log; + RootDirectory = rootDirectory; + listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + Origin = "http://127.0.0.1:" + port.ToString(CultureInfo.InvariantCulture); + acceptThread = new Thread(AcceptLoop) + { + IsBackground = true, + Name = "BD2 local resource server" + }; + acceptThread.Start(); + } + + internal string Origin { get; } + + internal string RootDirectory { get; } + + internal static LocalResourceServer Start( + ManualLogSource log, + string configuredDirectory, + string bundleVersion, + string gameDataVersion) + { + string root = ValidateRoot(configuredDirectory, bundleVersion, gameDataVersion); + LocalResourceServer server = new LocalResourceServer(log, root); + log.LogInfo("Local resource HTTP server listening on " + server.Origin); + return server; + } + + private static string ValidateRoot(string configuredDirectory, string bundleVersion, string gameDataVersion) + { + if (string.IsNullOrWhiteSpace(configuredDirectory)) + { + throw new InvalidDataException("local_resource_directory must not be empty"); + } + + string root; + try + { + root = Path.GetFullPath(configuredDirectory.Trim()); + } + catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) + { + throw new InvalidDataException("local_resource_directory is not a valid filesystem path", ex); + } + if (!Directory.Exists(root)) + { + throw new DirectoryNotFoundException("Local resource directory does not exist: " + root); + } + if ((File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException("Local resource directory must not be a symbolic link or junction: " + root); + } + + RequireDirectory(root, "ServerData"); + RequireDirectory(root, "GameData"); + RequireFile(root, "ServerData", "StandaloneWindows64", "HD", bundleVersion, "catalog_alpha.json"); + RequireFile(root, "ServerData", "StandaloneWindows64", "HD", bundleVersion, "catalog_alpha.hash"); + RequireFile(root, "GameData", gameDataVersion, "release", "common-dbdata.info"); + RequireFile(root, "GameData", gameDataVersion, "release", "common-dbdata.bin"); + string volumeRoot = Path.GetPathRoot(root); + if (root.Length > volumeRoot.Length) + { + root = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + return root; + } + + private static void RequireDirectory(string root, params string[] parts) + { + string path = Combine(root, parts); + if (!Directory.Exists(path)) + { + throw new DirectoryNotFoundException("Local resource directory is missing: " + path); + } + } + + private static void RequireFile(string root, params string[] parts) + { + string path = Combine(root, parts); + if (!File.Exists(path)) + { + throw new FileNotFoundException("Local resource directory is missing a release file", path); + } + } + + private static string Combine(string root, IEnumerable parts) + { + string result = root; + foreach (string part in parts) + { + result = Path.Combine(result, part); + } + return result; + } + + private void AcceptLoop() + { + while (!disposed) + { + TcpClient client; + try + { + client = listener.AcceptTcpClient(); + } + catch (SocketException) when (disposed) + { + return; + } + catch (ObjectDisposedException) when (disposed) + { + return; + } + catch (Exception ex) + { + if (!disposed) + { + log.LogError("Local resource accept failed: " + ex.Message); + } + continue; + } + + if (!requestSlots.WaitOne(0)) + { + client.Dispose(); + continue; + } + ThreadPool.QueueUserWorkItem(_ => + { + try + { + HandleClient(client); + } + catch (Exception ex) + { + log.LogWarning("Local resource request failed: " + ex.Message); + } + finally + { + client.Dispose(); + requestSlots.Release(); + } + }); + } + } + + private void HandleClient(TcpClient client) + { + client.ReceiveTimeout = 10_000; + client.SendTimeout = 30_000; + using NetworkStream stream = client.GetStream(); + string header = ReadRequestHeader(stream); + if (header == null) + { + WriteError(stream, 400, "Bad Request"); + return; + } + + string[] lines = header.Split(new[] { "\r\n" }, StringSplitOptions.None); + string[] requestLine = lines[0].Split(' '); + if (requestLine.Length != 3 || (requestLine[2] != "HTTP/1.1" && requestLine[2] != "HTTP/1.0")) + { + WriteError(stream, 400, "Bad Request"); + return; + } + bool head = requestLine[0] == "HEAD"; + if (!head && requestLine[0] != "GET") + { + WriteError(stream, 405, "Method Not Allowed", "Allow: GET, HEAD\r\n"); + return; + } + + Dictionary headers = ParseHeaders(lines); + string path = ResolvePath(requestLine[1]); + if (path == null || !File.Exists(path)) + { + WriteError(stream, 404, "Not Found"); + return; + } + + using FileStream file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + long start = 0; + long end = file.Length - 1; + bool partial = false; + if (headers.TryGetValue("Range", out string range)) + { + if (!TryParseRange(range, file.Length, out start, out end)) + { + WriteError(stream, 416, "Range Not Satisfiable", "Content-Range: bytes */" + file.Length + "\r\n"); + return; + } + partial = true; + } + + long contentLength = file.Length == 0 ? 0 : end - start + 1; + StringBuilder response = new StringBuilder(); + response.Append(partial ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n"); + response.Append("Content-Length: ").Append(contentLength).Append("\r\n"); + response.Append("Content-Type: ").Append(GetContentType(path)).Append("\r\n"); + response.Append("Accept-Ranges: bytes\r\n"); + if (partial) + { + response.Append("Content-Range: bytes ").Append(start).Append('-').Append(end).Append('/').Append(file.Length).Append("\r\n"); + } + response.Append("Cache-Control: public, max-age=31536000, immutable\r\n"); + response.Append("Connection: close\r\n\r\n"); + WriteAscii(stream, response.ToString()); + if (head || contentLength == 0) + { + return; + } + + file.Position = start; + byte[] buffer = new byte[128 * 1024]; + long remaining = contentLength; + while (remaining > 0) + { + int read = file.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining)); + if (read == 0) + { + throw new EndOfStreamException("Local resource file ended before its declared length"); + } + stream.Write(buffer, 0, read); + remaining -= read; + } + } + + private string ResolvePath(string requestTarget) + { + int query = requestTarget.IndexOfAny(new[] { '?', '#' }); + string rawPath = query >= 0 ? requestTarget.Substring(0, query) : requestTarget; + string decoded; + try + { + decoded = Uri.UnescapeDataString(rawPath); + } + catch + { + return null; + } + if (decoded.IndexOf('\0') >= 0) + { + return null; + } + + string[] parts = decoded.Replace('\\', '/').Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 || (parts[0] != "ServerData" && parts[0] != "GameData")) + { + return null; + } + foreach (string part in parts) + { + if (part == "." || part == ".." || part.IndexOf(':') >= 0) + { + return null; + } + } + + string candidate = Combine(RootDirectory, parts); + try + { + candidate = Path.GetFullPath(candidate); + } + catch + { + return null; + } + string prefix = RootDirectory + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || ContainsReparsePoint(parts)) + { + return null; + } + return candidate; + } + + private bool ContainsReparsePoint(string[] parts) + { + string current = RootDirectory; + foreach (string part in parts) + { + current = Path.Combine(current, part); + try + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + return true; + } + } + catch + { + return true; + } + } + return false; + } + + private static string ReadRequestHeader(Stream stream) + { + byte[] bytes = new byte[MaximumRequestHeaderBytes]; + int count = 0; + while (count < bytes.Length) + { + int read = stream.Read(bytes, count, 1); + if (read == 0) + { + return null; + } + count++; + if (count >= 4 && bytes[count - 4] == '\r' && bytes[count - 3] == '\n' && + bytes[count - 2] == '\r' && bytes[count - 1] == '\n') + { + return Encoding.ASCII.GetString(bytes, 0, count - 4); + } + } + return null; + } + + private static Dictionary ParseHeaders(string[] lines) + { + Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < lines.Length; i++) + { + int separator = lines[i].IndexOf(':'); + if (separator <= 0) + { + continue; + } + result[lines[i].Substring(0, separator).Trim()] = lines[i].Substring(separator + 1).Trim(); + } + return result; + } + + private static bool TryParseRange(string value, long length, out long start, out long end) + { + start = 0; + end = length - 1; + if (length == 0 || !value.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase) || value.IndexOf(',') >= 0) + { + return false; + } + string[] bounds = value.Substring(6).Split('-'); + if (bounds.Length != 2) + { + return false; + } + if (bounds[0].Length == 0) + { + if (!long.TryParse(bounds[1], NumberStyles.None, CultureInfo.InvariantCulture, out long suffix) || suffix <= 0) + { + return false; + } + start = Math.Max(0, length - suffix); + return true; + } + if (!long.TryParse(bounds[0], NumberStyles.None, CultureInfo.InvariantCulture, out start) || start < 0 || start >= length) + { + return false; + } + if (bounds[1].Length != 0 && + (!long.TryParse(bounds[1], NumberStyles.None, CultureInfo.InvariantCulture, out end) || end < start)) + { + return false; + } + end = Math.Min(end, length - 1); + return true; + } + + private static string GetContentType(string path) + { + switch (Path.GetExtension(path).ToLowerInvariant()) + { + case ".json": + return "application/json; charset=utf-8"; + case ".hash": + case ".info": + case ".version": + return "text/plain; charset=utf-8"; + default: + return "application/octet-stream"; + } + } + + private static void WriteError(Stream stream, int status, string reason, string additionalHeaders = "") + { + WriteAscii(stream, "HTTP/1.1 " + status + " " + reason + "\r\n" + additionalHeaders + + "Content-Length: 0\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"); + } + + private static void WriteAscii(Stream stream, string value) + { + byte[] bytes = Encoding.ASCII.GetBytes(value); + stream.Write(bytes, 0, bytes.Length); + } + + public void Dispose() + { + if (disposed) + { + return; + } + disposed = true; + listener.Stop(); + if (Thread.CurrentThread != acceptThread) + { + acceptThread.Join(1_000); + } + log.LogInfo("Local resource HTTP server stopped"); + } +} diff --git a/plugins/LocalIdentity/Plugin.cs b/plugins/LocalIdentity/Plugin.cs index 5294197..01d7a84 100644 --- a/plugins/LocalIdentity/Plugin.cs +++ b/plugins/LocalIdentity/Plugin.cs @@ -16,8 +16,9 @@ public sealed class Plugin : BaseUnityPlugin public const string Guid = "bd2.localidentity"; public const string Name = "BD2 Local Identity"; public const string Version = Bd2Build.Versions.Plugin; - private const string LocalServerURL = "http://127.0.0.1:8080/game/"; private static ManualLogSource Log; + private static ClientRouting Routing; + private static int ShutdownHooksInstalled; internal static void LogWarning(string message) { @@ -29,11 +30,17 @@ public sealed class Plugin : BaseUnityPlugin try { Log = Logger; - // The non-SDK branch creates/uses this local token and calls - // SendMaintenanceInfo directly, bypassing Neon account UI. + // The non-SDK branch still needs a local bootstrap identity for + // MaintenanceInfo. OAuth LoginUI replaces this value after its + // browser/device transaction completes. PlayerPrefs.SetString("AccessToken", "bd2-local-development-user"); PlayerPrefs.Save(); - + Routing = ClientRouting.Load(Logger); + if (Interlocked.Exchange(ref ShutdownHooksInstalled, 1) == 0) + { + AppDomain.CurrentDomain.ProcessExit += delegate { DisposeRouting(); }; + AppDomain.CurrentDomain.DomainUnload += delegate { DisposeRouting(); }; + } Type appManager = FindType("AppManager"); PropertyInfo useSdk = appManager?.GetProperty( "ὬὦὠὫὡὥὥὦὠὠὠ", @@ -51,21 +58,7 @@ public sealed class Plugin : BaseUnityPlugin harmony.Patch(getter, prefix: new HarmonyMethod(prefix)); Type introUI = FindType("IntroUI"); - MethodInfo sendMaintenance = introUI?.GetMethod( - "SendMaintenanceInfo", - BindingFlags.Instance | BindingFlags.Public, - null, - new[] { typeof(bool) }, - null); - MethodInfo maintenancePrefix = typeof(Plugin).GetMethod( - nameof(SendMaintenancePrefix), - BindingFlags.Static | BindingFlags.NonPublic); - if (sendMaintenance == null || maintenancePrefix == null) - { - throw new MissingMethodException("IntroUI.SendMaintenanceInfo(bool) was not found"); - } - harmony.Patch(sendMaintenance, prefix: new HarmonyMethod(maintenancePrefix)); - + InstallClientRouting(harmony, introUI); TryInstall("maintenance timeout guard", () => InstallMaintenanceTimeoutGuard(harmony, introUI)); TryInstall("age-gate persistence", () => InstallAgeGatePersistence(harmony)); TryInstall("local purchase bypass", () => InstallLocalPurchaseBypass(harmony)); @@ -78,6 +71,17 @@ public sealed class Plugin : BaseUnityPlugin } } + private void OnApplicationQuit() + { + DisposeRouting(); + } + + private static void DisposeRouting() + { + ClientRouting routing = Interlocked.Exchange(ref Routing, null); + routing?.Dispose(); + } + private void TryInstall(string name, Action install) { try @@ -96,18 +100,63 @@ public sealed class Plugin : BaseUnityPlugin return false; } - private static void SendMaintenancePrefix() + private static void InstallClientRouting(Harmony harmony, Type introUI) { - Type serverURLInfo = FindType("ὫὡὩὤὣὨὯὭὥὠὩ"); - FieldInfo maintenanceUri = serverURLInfo?.GetField( - "ὫὯὯὦὢὤὫὫὧὢὨ", - BindingFlags.Static | BindingFlags.Public); - if (maintenanceUri == null || maintenanceUri.FieldType != typeof(Uri)) + Type serverURLInfo = FindType("ὫὡὩὤὣὨὯὭὥὠὩ") ?? FindType("BDNetwork.ServerURLInfo"); + MethodInfo load = serverURLInfo?.GetMethod( + "ὣὪὦὮὦὠὪὯὧὯὭ", + BindingFlags.Static | BindingFlags.Public, + null, + Type.EmptyTypes, + null) ?? serverURLInfo?.GetMethod( + "Load", + BindingFlags.Static | BindingFlags.Public, + null, + Type.EmptyTypes, + null); + MethodInfo sendMaintenance = introUI?.GetMethod( + "SendMaintenanceInfo", + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(bool) }, + null); + Type commonPacket = FindType("ὣὡὧὡὦὣὣὬὨὪὫ"); + MethodInfo makeCDNInfo = commonPacket?.GetMethod( + "ὥὢὨὡὪὨὥὥὪὩὠ", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { typeof(string) }, + null); + if (load == null || sendMaintenance == null || makeCDNInfo == null) { - throw new MissingFieldException("Client maintenance URI field was not found"); + throw new MissingMethodException("2.35.10 server/resource routing methods were not found"); } - maintenanceUri.SetValue(null, new Uri(LocalServerURL)); - Log?.LogInfo("MaintenanceUri => " + LocalServerURL); + harmony.Patch(load, postfix: new HarmonyMethod(typeof(Plugin), nameof(ApplyServerOrigin))); + harmony.Patch(sendMaintenance, prefix: new HarmonyMethod(typeof(Plugin), nameof(ApplyServerOrigin))); + harmony.Patch(makeCDNInfo, postfix: new HarmonyMethod(typeof(Plugin), nameof(ApplyResources))); + Log?.LogInfo("Server origin and native CdnInfo routing patches installed"); + } + + private static void ApplyServerOrigin() + { + ClientRouting routing = Routing; + if (routing == null) + { + Log?.LogError("Client routing is unavailable while applying the server origin"); + return; + } + routing.ApplyServerOrigin(); + } + + private static void ApplyResources() + { + ClientRouting routing = Routing; + if (routing == null) + { + Log?.LogError("Client routing is unavailable while applying resources"); + return; + } + routing.ApplyResources(); } private static void InstallMaintenanceTimeoutGuard(Harmony harmony, Type introUI) @@ -396,7 +445,7 @@ public sealed class Plugin : BaseUnityPlugin return field?.GetValue(value) as string; } - private static Type FindType(string name) + internal static Type FindType(string name) { Assembly assembly = Assembly.Load("Assembly-CSharp"); Type type = assembly?.GetType(name); diff --git a/plugins/LocalIdentity/bd2.client.example.json b/plugins/LocalIdentity/bd2.client.example.json new file mode 100644 index 0000000..2623798 --- /dev/null +++ b/plugins/LocalIdentity/bd2.client.example.json @@ -0,0 +1,6 @@ +{ + "schema_version": 2, + "server_origin": "http://127.0.0.1:8080", + "cdn_mode": "local", + "local_resource_directory": "E:\\bd2\\dl" +} diff --git a/plugins/LoginUI/Assets/Discord-Symbol.png b/plugins/LoginUI/Assets/Discord-Symbol.png new file mode 100644 index 0000000..044056a Binary files /dev/null and b/plugins/LoginUI/Assets/Discord-Symbol.png differ diff --git a/plugins/LoginUI/Assets/Discord-Wordmark.png b/plugins/LoginUI/Assets/Discord-Wordmark.png new file mode 100644 index 0000000..ab44736 Binary files /dev/null and b/plugins/LoginUI/Assets/Discord-Wordmark.png differ diff --git a/plugins/LoginUI/Assets/README.md b/plugins/LoginUI/Assets/README.md new file mode 100644 index 0000000..5cdfc48 --- /dev/null +++ b/plugins/LoginUI/Assets/README.md @@ -0,0 +1,13 @@ +# Discord brand assets + +`Discord-Symbol.png` and `Discord-Wordmark.png` are derived from the official +Discord Brand Guidelines assets, retrieved on 2026-09-29: + +- +- +- + +The SVG files were rendered with a transparent background by a headless local +browser and normalized to RGBA PNG with FFmpeg. Discord's marks remain subject +to Discord's brand guidelines and are not covered by this repository's +license. diff --git a/plugins/LoginUI/CredentialStores.cs b/plugins/LoginUI/CredentialStores.cs new file mode 100644 index 0000000..f152578 --- /dev/null +++ b/plugins/LoginUI/CredentialStores.cs @@ -0,0 +1,548 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using UnityEngine; + +namespace Bd2LoginUI; + +internal sealed class MemoryAccessTokenStore +{ + private string value; + + public string Get() + { + return value ?? string.Empty; + } + + public void Set(string token) + { + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentException("access token is empty", nameof(token)); + } + value = token; + } + + public void Clear() + { + value = null; + } +} + +internal interface IRefreshCredentialStore +{ + bool IsSupported { get; } + bool Contains(string origin); + RefreshCredential Load(string origin); + void Save(string origin, RefreshCredential credential); + void Delete(string origin); +} + +[Serializable] +internal sealed class RefreshCredential +{ + public int version; + public string origin; + public string provider; + public string refresh_token; + public long expires_at; +} + +internal static class PlatformRefreshCredentialStore +{ + public static IRefreshCredentialStore Create() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new WindowsDpapiRefreshCredentialStore(); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return new MacOSKeychainRefreshCredentialStore(); + } + return new UnsupportedRefreshCredentialStore(); + } +} + +internal sealed class UnsupportedRefreshCredentialStore : IRefreshCredentialStore +{ + public bool IsSupported => false; + + public bool Contains(string origin) + { + return false; + } + + public RefreshCredential Load(string origin) + { + throw new PlatformNotSupportedException("secure refresh credential storage is unavailable on this platform"); + } + + public void Save(string origin, RefreshCredential credential) + { + throw new PlatformNotSupportedException("secure refresh credential storage is unavailable on this platform"); + } + + public void Delete(string origin) + { + } +} + +internal sealed class WindowsDpapiRefreshCredentialStore : IRefreshCredentialStore +{ + private const string Purpose = "BD2.LoginUI.Refresh.v1"; + private const string PreferencePrefix = "BD2OAuthRefreshV1_"; + private const uint CryptProtectUiForbidden = 0x1; + + public bool IsSupported => true; + + public bool Contains(string origin) + { + return PlayerPrefs.HasKey(PreferenceFor(origin)); + } + + public RefreshCredential Load(string origin) + { + string encoded = PlayerPrefs.GetString(PreferenceFor(origin), string.Empty); + if (string.IsNullOrEmpty(encoded)) + { + throw new InvalidDataException("saved Windows credential is empty"); + } + byte[] cipher; + try + { + cipher = Convert.FromBase64String(encoded); + } + catch (FormatException ex) + { + throw new InvalidDataException("saved Windows credential is malformed", ex); + } + byte[] entropy = Entropy(origin); + byte[] plain = null; + try + { + plain = Unprotect(cipher, entropy); + string json = Encoding.UTF8.GetString(plain); + return JsonUtility.FromJson(json); + } + finally + { + Clear(cipher); + Clear(entropy); + Clear(plain); + } + } + + public void Save(string origin, RefreshCredential credential) + { + byte[] plain = Encoding.UTF8.GetBytes(JsonUtility.ToJson(credential)); + byte[] entropy = Entropy(origin); + byte[] cipher = null; + try + { + cipher = Protect(plain, entropy); + PlayerPrefs.SetString(PreferenceFor(origin), Convert.ToBase64String(cipher)); + PlayerPrefs.Save(); + } + finally + { + Clear(plain); + Clear(entropy); + Clear(cipher); + } + } + + public void Delete(string origin) + { + PlayerPrefs.DeleteKey(PreferenceFor(origin)); + PlayerPrefs.Save(); + } + + private static string PreferenceFor(string origin) + { + byte[] input = Encoding.UTF8.GetBytes(origin); + byte[] digest; + using (SHA256 sha = SHA256.Create()) + { + digest = sha.ComputeHash(input); + } + try + { + return PreferencePrefix + Hex(digest); + } + finally + { + Clear(input); + Clear(digest); + } + } + + private static byte[] Entropy(string origin) + { + return Encoding.UTF8.GetBytes(Purpose + "\0" + origin); + } + + private static byte[] Protect(byte[] plain, byte[] entropy) + { + DataBlob input = Allocate(plain); + DataBlob optionalEntropy = Allocate(entropy); + DataBlob output = default; + try + { + if (!CryptProtectData(ref input, Purpose, ref optionalEntropy, IntPtr.Zero, IntPtr.Zero, CryptProtectUiForbidden, out output)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows DPAPI could not protect the refresh credential"); + } + return Copy(output); + } + finally + { + ZeroAndFreeHGlobal(ref input); + ZeroAndFreeHGlobal(ref optionalEntropy); + LocalFreeBlob(ref output, false); + } + } + + private static byte[] Unprotect(byte[] cipher, byte[] entropy) + { + DataBlob input = Allocate(cipher); + DataBlob optionalEntropy = Allocate(entropy); + DataBlob output = default; + IntPtr description = IntPtr.Zero; + try + { + if (!CryptUnprotectData(ref input, out description, ref optionalEntropy, IntPtr.Zero, IntPtr.Zero, CryptProtectUiForbidden, out output)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows DPAPI could not unprotect the refresh credential"); + } + return Copy(output); + } + finally + { + ZeroAndFreeHGlobal(ref input); + ZeroAndFreeHGlobal(ref optionalEntropy); + LocalFreeBlob(ref output, true); + if (description != IntPtr.Zero) + { + LocalFree(description); + } + } + } + + private static DataBlob Allocate(byte[] bytes) + { + if (bytes == null || bytes.Length == 0) + { + return default; + } + DataBlob blob = new DataBlob { size = bytes.Length, data = Marshal.AllocHGlobal(bytes.Length) }; + Marshal.Copy(bytes, 0, blob.data, bytes.Length); + return blob; + } + + private static byte[] Copy(DataBlob blob) + { + if (blob.size <= 0 || blob.data == IntPtr.Zero) + { + throw new InvalidDataException("platform credential store returned an empty value"); + } + byte[] bytes = new byte[blob.size]; + Marshal.Copy(blob.data, bytes, 0, bytes.Length); + return bytes; + } + + private static void ZeroAndFreeHGlobal(ref DataBlob blob) + { + if (blob.data == IntPtr.Zero) + { + return; + } + ZeroUnmanaged(blob.data, blob.size); + Marshal.FreeHGlobal(blob.data); + blob = default; + } + + private static void LocalFreeBlob(ref DataBlob blob, bool clear) + { + if (blob.data == IntPtr.Zero) + { + return; + } + if (clear) + { + ZeroUnmanaged(blob.data, blob.size); + } + LocalFree(blob.data); + blob = default; + } + + private static void ZeroUnmanaged(IntPtr data, int length) + { + for (int i = 0; i < length; i++) + { + Marshal.WriteByte(data, i, 0); + } + } + + private static string Hex(byte[] value) + { + StringBuilder builder = new StringBuilder(value.Length * 2); + foreach (byte item in value) + { + builder.Append(item.ToString("x2")); + } + return builder.ToString(); + } + + private static void Clear(byte[] bytes) + { + if (bytes != null) + { + Array.Clear(bytes, 0, bytes.Length); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct DataBlob + { + public int size; + public IntPtr data; + } + + [DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptProtectData( + ref DataBlob dataIn, + string description, + ref DataBlob optionalEntropy, + IntPtr reserved, + IntPtr prompt, + uint flags, + out DataBlob dataOut); + + [DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptUnprotectData( + ref DataBlob dataIn, + out IntPtr description, + ref DataBlob optionalEntropy, + IntPtr reserved, + IntPtr prompt, + uint flags, + out DataBlob dataOut); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr LocalFree(IntPtr memory); +} + +internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialStore +{ + private const int Success = 0; + private const int ItemNotFound = -25300; + private const string Account = "refresh-v1"; + + public bool IsSupported => true; + + public bool Contains(string origin) + { + int status = Find(origin, out uint length, out IntPtr data, out IntPtr item); + ReleaseFound(length, data, item); + if (status == Success) + { + return true; + } + if (status == ItemNotFound) + { + return false; + } + throw StatusException(status); + } + + public RefreshCredential Load(string origin) + { + int status = Find(origin, out uint length, out IntPtr data, out IntPtr item); + if (status != Success) + { + ReleaseFound(length, data, item); + throw StatusException(status); + } + byte[] bytes = new byte[length]; + try + { + Marshal.Copy(data, bytes, 0, bytes.Length); + return JsonUtility.FromJson(Encoding.UTF8.GetString(bytes)); + } + finally + { + Array.Clear(bytes, 0, bytes.Length); + ReleaseFound(length, data, item); + } + } + + public void Save(string origin, RefreshCredential credential) + { + byte[] value = Encoding.UTF8.GetBytes(JsonUtility.ToJson(credential)); + try + { + int status = Find(origin, out uint oldLength, out IntPtr oldData, out IntPtr item); + if (status == Success) + { + if (oldData != IntPtr.Zero) + { + SecKeychainItemFreeContent(IntPtr.Zero, oldData); + } + try + { + status = SecKeychainItemModifyAttributesAndData(item, IntPtr.Zero, (uint)value.Length, value); + } + finally + { + ReleaseItem(item); + } + } + else if (status == ItemNotFound) + { + byte[] service = Service(origin); + byte[] account = Encoding.UTF8.GetBytes(Account); + try + { + status = SecKeychainAddGenericPassword(IntPtr.Zero, (uint)service.Length, service, (uint)account.Length, account, (uint)value.Length, value, out item); + ReleaseItem(item); + } + finally + { + Array.Clear(service, 0, service.Length); + Array.Clear(account, 0, account.Length); + } + } + else + { + ReleaseFound(oldLength, oldData, item); + } + if (status != Success) + { + throw StatusException(status); + } + } + finally + { + Array.Clear(value, 0, value.Length); + } + } + + public void Delete(string origin) + { + int status = Find(origin, out uint length, out IntPtr data, out IntPtr item); + if (data != IntPtr.Zero) + { + SecKeychainItemFreeContent(IntPtr.Zero, data); + } + if (status == ItemNotFound) + { + ReleaseItem(item); + return; + } + if (status != Success) + { + ReleaseItem(item); + throw StatusException(status); + } + try + { + status = SecKeychainItemDelete(item); + } + finally + { + ReleaseItem(item); + } + if (status != Success) + { + throw StatusException(status); + } + } + + private static int Find(string origin, out uint length, out IntPtr data, out IntPtr item) + { + byte[] service = Service(origin); + byte[] account = Encoding.UTF8.GetBytes(Account); + try + { + return SecKeychainFindGenericPassword(IntPtr.Zero, (uint)service.Length, service, (uint)account.Length, account, out length, out data, out item); + } + finally + { + Array.Clear(service, 0, service.Length); + Array.Clear(account, 0, account.Length); + } + } + + private static byte[] Service(string origin) + { + return Encoding.UTF8.GetBytes("BD2 Login UI OAuth: " + origin); + } + + private static void ReleaseFound(uint length, IntPtr data, IntPtr item) + { + if (data != IntPtr.Zero) + { + SecKeychainItemFreeContent(IntPtr.Zero, data); + } + ReleaseItem(item); + } + + private static void ReleaseItem(IntPtr item) + { + if (item != IntPtr.Zero) + { + CFRelease(item); + } + } + + private static Exception StatusException(int status) + { + return status == ItemNotFound + ? new FileNotFoundException("refresh credential was not found in macOS Keychain") + : new InvalidOperationException("macOS Keychain operation failed with OSStatus " + status); + } + + [DllImport("/System/Library/Frameworks/Security.framework/Security")] + private static extern int SecKeychainFindGenericPassword( + IntPtr keychainOrArray, + uint serviceNameLength, + byte[] serviceName, + uint accountNameLength, + byte[] accountName, + out uint passwordLength, + out IntPtr passwordData, + out IntPtr itemRef); + + [DllImport("/System/Library/Frameworks/Security.framework/Security")] + private static extern int SecKeychainAddGenericPassword( + IntPtr keychain, + uint serviceNameLength, + byte[] serviceName, + uint accountNameLength, + byte[] accountName, + uint passwordLength, + byte[] passwordData, + out IntPtr itemRef); + + [DllImport("/System/Library/Frameworks/Security.framework/Security")] + private static extern int SecKeychainItemModifyAttributesAndData( + IntPtr itemRef, + IntPtr attrList, + uint length, + byte[] data); + + [DllImport("/System/Library/Frameworks/Security.framework/Security")] + private static extern int SecKeychainItemDelete(IntPtr itemRef); + + [DllImport("/System/Library/Frameworks/Security.framework/Security")] + private static extern int SecKeychainItemFreeContent(IntPtr attrList, IntPtr data); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern void CFRelease(IntPtr item); +} diff --git a/plugins/LoginUI/LoginUI.csproj b/plugins/LoginUI/LoginUI.csproj new file mode 100644 index 0000000..366556a --- /dev/null +++ b/plugins/LoginUI/LoginUI.csproj @@ -0,0 +1,58 @@ + + + netstandard2.1 + latest + disable + BD2LoginUI + Bd2LoginUI + login_ui + $(GameDir)\BrownDust II_Data\Managed + $(GameDir)\BepInEx + + + + + + + $(BD2BepInExDir)\core\BepInEx.dll + false + + + $(BD2BepInExDir)\core\0Harmony.dll + false + + + $(BD2ManagedDir)\UnityEngine.CoreModule.dll + false + + + $(BD2ManagedDir)\UnityEngine.dll + false + + + $(BD2ManagedDir)\UnityEngine.ImageConversionModule.dll + false + + + $(BD2ManagedDir)\UnityEngine.JSONSerializeModule.dll + false + + + $(BD2ManagedDir)\UnityEngine.UnityWebRequestModule.dll + false + + + $(BD2ManagedDir)\UnityEngine.UI.dll + false + + + $(BD2ManagedDir)\UnityEngine.UIModule.dll + false + + + + + + + + diff --git a/plugins/LoginUI/Plugin.cs b/plugins/LoginUI/Plugin.cs new file mode 100644 index 0000000..f893879 --- /dev/null +++ b/plugins/LoginUI/Plugin.cs @@ -0,0 +1,1036 @@ +using System; +using System.Collections; +using System.IO; +using System.Reflection; +using System.Text; +using BepInEx; +using BepInEx.Logging; +using HarmonyLib; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.Networking; +using UnityEngine.UI; + +namespace Bd2LoginUI; + +[BepInPlugin(Guid, Name, Version)] +[BepInDependency("bd2.localidentity", BepInDependency.DependencyFlags.HardDependency)] +public sealed class Plugin : BaseUnityPlugin +{ + public const string Guid = "bd2.login.ui"; + public const string Name = "BD2 Login UI"; + public const string Version = Bd2Build.Versions.Plugin; + + private const string SymbolResource = "Bd2LoginUI.Assets.Discord-Symbol.png"; + private const string WordmarkResource = "Bd2LoginUI.Assets.Discord-Wordmark.png"; + private const string LocalAccessToken = "bd2-local-development-user"; + private static readonly Color DiscordBlurple = new Color32(88, 101, 242, 255); + + private static ManualLogSource Log; + private static Sprite DiscordSymbol; + private static Sprite DiscordWordmark; + private static MethodInfo SetIntroState; + private static MethodInfo SendMaintenance; + private static ServerAuthentication Authentication; + private static Uri ServerRoot; + private static string AuthenticationLoadingOrigin; + private static bool ContinueMaintenance; + private static bool LoginInProgress; + private static MethodInfo OpenPCLoginPopup; + private static MemoryAccessTokenStore AccessTokens; + private static IRefreshCredentialStore RefreshCredentials; + + private void Awake() + { + try + { + Log = Logger; + AccessTokens = new MemoryAccessTokenStore(); + RefreshCredentials = PlatformRefreshCredentialStore.Create(); + DiscordSymbol = LoadSprite(SymbolResource, "BD2 Discord Symbol"); + DiscordWordmark = LoadSprite(WordmarkResource, "BD2 Discord Wordmark"); + + Type introUI = FindType("IntroUI"); + MethodInfo awake = introUI?.GetMethod( + "Awake", + BindingFlags.Instance | BindingFlags.NonPublic, + null, + Type.EmptyTypes, + null); + if (awake == null || awake.ReturnType != typeof(void)) + { + throw new MissingMethodException("IntroUI.Awake() was not found (client version mismatch)"); + } + SendMaintenance = introUI.GetMethod( + "SendMaintenanceInfo", + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(bool) }, + null); + SetIntroState = FindSetIntroState(introUI); + OpenPCLoginPopup = FindOpenPCLoginPopup(); + MethodInfo accessTokenGetter = FindAccessTokenGetter(); + MethodInfo clearPCLocalData = FindClearPCLocalData(); + if (SendMaintenance == null || SetIntroState == null || OpenPCLoginPopup == null || + accessTokenGetter == null || clearPCLocalData == null) + { + throw new MissingMethodException("IntroUI authentication transition methods were not found (client version mismatch)"); + } + + Harmony harmony = new Harmony(Guid); + harmony.Patch(awake, postfix: new HarmonyMethod(typeof(Plugin), nameof(IntroAwakePostfix))); + HarmonyMethod maintenancePrefix = new HarmonyMethod(typeof(Plugin), nameof(SendMaintenancePrefix)); + maintenancePrefix.after = new[] { "bd2.localidentity" }; + harmony.Patch(SendMaintenance, prefix: maintenancePrefix); + harmony.Patch( + accessTokenGetter, + prefix: new HarmonyMethod(typeof(Plugin), nameof(AccessTokenPrefix))); + harmony.Patch( + clearPCLocalData, + postfix: new HarmonyMethod(typeof(Plugin), nameof(ClearPCLocalDataPostfix))); + Logger.LogInfo("Server-authoritative Discord and Google login UI patch installed"); + } + catch (Exception ex) + { + Logger.LogError("Login UI patch failed: " + ex); + } + } + + private static void IntroAwakePostfix(object __instance) + { + ConfigureLoginPanel(__instance); + } + + private static bool AccessTokenPrefix(ref string __result) + { + if (Authentication != null && Authentication.mode == "oauth") + { + __result = AccessTokens.Get(); + return false; + } + __result = LocalAccessToken; + return false; + } + + private static void ClearPCLocalDataPostfix() + { + AccessTokens.Clear(); + PlayerPrefs.DeleteKey("AccessToken"); + DeleteCurrentRefresh(); + PlayerPrefs.Save(); + } + + private static bool SendMaintenancePrefix(object __instance, bool __0) + { + if (!__0 || ContinueMaintenance) + { + return true; + } + try + { + if (!(__instance is Component)) + { + throw new InvalidOperationException("IntroUI is not a Unity component"); + } + Uri maintenance = CurrentMaintenanceUri(); + Uri currentRoot = new Uri(maintenance, "/"); + if (ServerRoot == null || !SameOrigin(ServerRoot, currentRoot)) + { + AccessTokens.Clear(); + Authentication = null; + LoginInProgress = false; + ServerRoot = currentRoot; + PlayerPrefs.DeleteKey("AccessToken"); + PlayerPrefs.Save(); + } + if (Authentication != null) + { + ApplyAuthenticationPolicy(__instance); + return false; + } + string origin = NormalizedServerOrigin(); + if (AuthenticationLoadingOrigin == origin) + { + return false; + } + AuthenticationLoadingOrigin = origin; + StartIntroCoroutine(__instance, LoadAuthenticationPolicy(__instance, currentRoot, origin)); + return false; + } + catch (Exception ex) + { + Log?.LogError("Could not request the server authentication policy: " + ex); + return false; + } + } + + private static IEnumerator LoadAuthenticationPolicy(object introUI, Uri expectedRoot, string expectedOrigin) + { + Uri endpoint = new Uri(expectedRoot, "auth/config"); + using (UnityWebRequest request = UnityWebRequest.Get(endpoint)) + { + request.timeout = 10; + yield return request.SendWebRequest(); + if (AuthenticationLoadingOrigin == expectedOrigin) + { + AuthenticationLoadingOrigin = null; + } + if (ServerRoot == null || !SameOrigin(ServerRoot, expectedRoot)) + { + yield break; + } + if (request.result != UnityWebRequest.Result.Success) + { + Log?.LogError("Authentication policy request failed: " + request.error); + yield break; + } + try + { + ServerAuthentication policy = JsonUtility.FromJson(request.downloadHandler.text); + ValidateAuthentication(policy); + Authentication = policy; + Log?.LogInfo("Server authentication mode: " + policy.mode); + ApplyAuthenticationPolicy(introUI); + } + catch (Exception ex) + { + Log?.LogError("Server returned an invalid authentication policy: " + ex.Message); + } + } + } + + private static void ApplyAuthenticationPolicy(object introUI) + { + if (Authentication.mode == "local") + { + try + { + AccessTokens.Clear(); + PlayerPrefs.DeleteKey("AccessToken"); + PlayerPrefs.Save(); + ContinueMaintenance = true; + SendMaintenance.Invoke(introUI, new object[] { true }); + } + finally + { + ContinueMaintenance = false; + } + return; + } + ValidateOAuthTransport(ServerRoot); + // Earlier development builds stored both local identifiers and OAuth + // access credentials in this key. OAuth credentials now live only in + // process memory, so remove any legacy plaintext before proceeding. + PlayerPrefs.DeleteKey("AccessToken"); + if (!RefreshCredentials.IsSupported) + { + PlayerPrefs.SetInt("IsAutoLogin", 0); + PlayerPrefs.SetInt("StandaloneAutoLogin", 0); + Log?.LogWarning("Secure refresh credential storage is unavailable; automatic login is disabled on this platform"); + } + PlayerPrefs.Save(); + if (LoginInProgress) + { + return; + } + if (PlayerPrefs.GetInt("IsAutoLogin", 0) != 0 && + PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0 && + CanAttemptAutomaticLogin()) + { + LoginInProgress = true; + StartIntroCoroutine(introUI, RefreshSession(introUI)); + } + else + { + ShowLoginPanel(introUI); + } + } + + private static void ShowLoginPanel(object introUI) + { + AccessTokens.Clear(); + LoginInProgress = false; + ConfigureLoginPanel(introUI); + Type stateType = SetIntroState.GetParameters()[0].ParameterType; + SetIntroState.Invoke(introUI, new[] { Enum.ToObject(stateType, 1) }); + Log?.LogInfo("Waiting for server-authorized third-party authentication"); + } + + private static void ConfigureLoginPanel(object introUI) + { + if (Authentication == null || Authentication.mode != "oauth") + { + return; + } + try + { + Component component = introUI as Component; + Transform panel = component == null ? null : FindDescendant(component.transform, "SignInWithAccount"); + if (panel == null) + { + throw new MissingMemberException("IntroUI/SignInWithAccount was not found"); + } + + Button google = FindButton(panel, "Button - Google"); + Button discord = FindButton(panel, "Button - Facebook"); + if (discord == null) + { + discord = FindButton(panel, "Button - Discord"); + } + if (google == null || discord == null) + { + throw new MissingMemberException("Google or Facebook/Discord login button was not found"); + } + + Image box = FindImage(discord.transform, "Image - Box"); + Image logo = FindImage(discord.transform, "Image - Logo"); + Image title = FindImage(discord.transform, "Image - Title"); + if (box == null || logo == null || title == null) + { + throw new MissingMemberException("Discord button images were not found"); + } + + SetActive(panel, "Button - Apple", false); + SetActive(panel, "Button - Email", false); + SetActive(panel, "Button - Mail", false); + SetActive(panel, "Button - Guest", false); + + google.gameObject.SetActive(ProviderEnabled("google")); + discord.gameObject.SetActive(ProviderEnabled("discord")); + discord.transform.SetSiblingIndex(0); + google.transform.SetSiblingIndex(1); + discord.gameObject.name = "Button - Discord"; + + ReplaceClick(google, introUI, "google"); + ReplaceClick(discord, introUI, "discord"); + ApplyDiscordBrand(box, logo, title); + + Canvas.ForceUpdateCanvases(); + if (panel is RectTransform panelRect) + { + LayoutRebuilder.ForceRebuildLayoutImmediate(panelRect); + } + } + catch (Exception ex) + { + Log?.LogError("Could not configure login panel: " + ex); + } + } + + private static void ReplaceClick(Button button, object introUI, string provider) + { + // Assigning a fresh event removes both serialized persistent calls and + // the listeners that IntroUI.Awake adds at runtime. + button.onClick = new Button.ButtonClickedEvent(); + button.onClick.AddListener(new UnityAction(delegate { OpenLogin(introUI, provider); })); + button.interactable = true; + } + + private static void OpenLogin(object introUI, string provider) + { + try + { + if (LoginInProgress) + { + return; + } + PropertyInfo canInteraction = introUI?.GetType().GetProperty( + "CanInteraction", + BindingFlags.Instance | BindingFlags.Public); + if (canInteraction != null && canInteraction.PropertyType == typeof(bool) && + !(bool)canInteraction.GetValue(introUI, null)) + { + return; + } + + if (!ProviderEnabled(provider)) + { + throw new InvalidOperationException("Provider is not enabled by this server"); + } + LoginInProgress = true; + StartIntroCoroutine(introUI, DeviceLogin(introUI, provider)); + } + catch (Exception ex) + { + Log?.LogError("Could not start " + provider + " authentication: " + ex.Message); + } + } + + private static IEnumerator DeviceLogin(object introUI, string provider) + { + string endpoint = new Uri(ServerRoot, "auth/device").AbsoluteUri; + byte[] body = Encoding.UTF8.GetBytes(JsonUtility.ToJson(new DeviceRequest { provider = provider })); + using (UnityWebRequest request = JsonPost(endpoint, body)) + { + yield return request.SendWebRequest(); + Array.Clear(body, 0, body.Length); + if (request.result != UnityWebRequest.Result.Success) + { + LoginInProgress = false; + Log?.LogError("Could not create login transaction: " + request.error); + yield break; + } + DeviceStart start; + try + { + start = JsonUtility.FromJson(request.downloadHandler.text); + if (start == null || string.IsNullOrEmpty(start.transaction_id) || string.IsNullOrEmpty(start.device_secret) || string.IsNullOrEmpty(start.start_url)) + { + throw new InvalidDataException("incomplete transaction response"); + } + ValidateBrowserURL(start.start_url); + } + catch (Exception ex) + { + LoginInProgress = false; + Log?.LogError("Invalid login transaction: " + ex.Message); + yield break; + } + Application.OpenURL(start.start_url); + yield return PollDevice(introUI, start); + } + } + + private static IEnumerator PollDevice(object introUI, DeviceStart start) + { + int delay = Math.Max(1, start.poll_interval); + float deadline = Time.realtimeSinceStartup + Math.Max(30, start.expires_in); + string endpoint = new Uri(ServerRoot, "auth/device/" + Uri.EscapeDataString(start.transaction_id) + "/poll").AbsoluteUri; + while (Time.realtimeSinceStartup < deadline) + { + yield return new WaitForSecondsRealtime(delay); + using (UnityWebRequest request = JsonPost(endpoint, Array.Empty())) + { + request.SetRequestHeader("Authorization", "Device " + start.device_secret); + yield return request.SendWebRequest(); + if (request.responseCode == 202) + { + continue; + } + if (request.result != UnityWebRequest.Result.Success) + { + LoginInProgress = false; + Log?.LogError("Login transaction failed: HTTP " + request.responseCode); + yield break; + } + TokenResult result; + try + { + result = JsonUtility.FromJson(request.downloadHandler.text); + } + catch (Exception ex) + { + LoginInProgress = false; + Log?.LogError("Login transaction returned invalid credentials: " + ex.Message); + yield break; + } + if (!ValidTokenResult(result) || !ProviderEnabled(result.provider)) + { + LoginInProgress = false; + Log?.LogError("Login transaction returned incomplete credentials"); + yield break; + } + CompleteInteractiveLogin(introUI, result); + yield break; + } + } + LoginInProgress = false; + Log?.LogError("Login transaction expired"); + } + + private static void CompleteInteractiveLogin(object introUI, TokenResult result) + { + if (!RefreshCredentials.IsSupported) + { + AccessTokens.Set(result.access_token); + result.access_token = null; + result.refresh_token = null; + PlayerPrefs.SetInt("IsAutoLogin", 0); + PlayerPrefs.SetInt("StandaloneAutoLogin", 0); + PlayerPrefs.DeleteKey("AccessToken"); + PlayerPrefs.Save(); + Log?.LogWarning("Login succeeded, but automatic login remains disabled because this platform has no supported secure credential store"); + ContinueWithMaintenance(introUI, false); + return; + } + Action confirmed = delegate + { + try + { + bool autoLogin = PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0; + if (autoLogin) + { + StoreRefresh(result); + } + else + { + DeleteCurrentRefresh(); + result.refresh_token = null; + } + AccessTokens.Set(result.access_token); + result.access_token = null; + PlayerPrefs.SetInt("IsAutoLogin", autoLogin ? 1 : 0); + PlayerPrefs.DeleteKey("AccessToken"); + PlayerPrefs.Save(); + ContinueWithMaintenance(introUI, false); + } + catch (Exception ex) + { + result.access_token = null; + result.refresh_token = null; + Log?.LogError("Could not finish interactive login: " + ex.Message); + ClearSavedLogin(); + ShowLoginPanel(introUI); + } + }; + try + { + OpenPCLoginPopup.Invoke(null, new object[] { confirmed }); + } + catch + { + result.access_token = null; + result.refresh_token = null; + LoginInProgress = false; + throw; + } + } + + private static IEnumerator RefreshSession(object introUI) + { + RefreshCredential saved; + try + { + saved = LoadRefresh(); + } + catch (Exception ex) + { + Log?.LogError("Saved automatic login could not be decrypted: " + ex.Message); + ClearSavedLogin(); + ShowLoginPanel(introUI); + yield break; + } + byte[] body = BuildRefreshRequest(saved.refresh_token); + saved.refresh_token = null; + using (UnityWebRequest request = JsonPost(new Uri(ServerRoot, "auth/session/refresh").AbsoluteUri, body)) + { + yield return request.SendWebRequest(); + Array.Clear(body, 0, body.Length); + if (request.responseCode == 401 || request.responseCode == 403) + { + ClearSavedLogin(); + ShowLoginPanel(introUI); + yield break; + } + if (request.result != UnityWebRequest.Result.Success) + { + // Refresh tokens are single-use. A transport failure can occur + // after the server committed rotation, so retrying the saved + // token could be interpreted as replay and revoke its family. + Log?.LogError("Automatic login temporarily unavailable: " + request.error); + ClearSavedLogin(); + ShowLoginPanel(introUI); + yield break; + } + TokenResult result; + try + { + result = JsonUtility.FromJson(request.downloadHandler.text); + } + catch (Exception ex) + { + Log?.LogError("Automatic login returned invalid credentials: " + ex.Message); + ClearSavedLogin(); + ShowLoginPanel(introUI); + yield break; + } + if (!ValidTokenResult(result) || !ProviderEnabled(result.provider)) + { + Log?.LogError("Automatic login returned incomplete credentials"); + ClearSavedLogin(); + ShowLoginPanel(introUI); + yield break; + } + // Persist the rotated refresh credential before exposing the new + // access credential. The old plaintext exists only in managed + // memory until the request body is cleared above. + try + { + StoreRefresh(result); + AccessTokens.Set(result.access_token); + result.access_token = null; + PlayerPrefs.DeleteKey("AccessToken"); + PlayerPrefs.Save(); + } + catch (Exception ex) + { + result.access_token = null; + result.refresh_token = null; + Log?.LogError("Could not persist the rotated automatic-login credential: " + ex.Message); + ClearSavedLogin(); + ShowLoginPanel(introUI); + yield break; + } + ContinueWithMaintenance(introUI, true); + } + } + + private static void ContinueWithMaintenance(object introUI, bool automatic) + { + try + { + ContinueMaintenance = true; + SendMaintenance.Invoke(introUI, new object[] { automatic }); + } + finally + { + ContinueMaintenance = false; + LoginInProgress = false; + } + } + + private static UnityWebRequest JsonPost(string url, byte[] body) + { + UnityWebRequest request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST) + { + uploadHandler = new UploadHandlerRaw(body), + downloadHandler = new DownloadHandlerBuffer(), + timeout = 15 + }; + request.SetRequestHeader("Content-Type", "application/json"); + return request; + } + + private static void StartIntroCoroutine(object introUI, IEnumerator routine) + { + if (!(introUI is MonoBehaviour owner) || owner == null) + { + throw new InvalidOperationException("IntroUI coroutine owner is unavailable"); + } + owner.StartCoroutine(routine ?? throw new ArgumentNullException(nameof(routine))); + } + + private static bool CanAttemptAutomaticLogin() + { + if (!RefreshCredentials.IsSupported) + { + return false; + } + try + { + return RefreshCredentials.Contains(NormalizedServerOrigin()); + } + catch (Exception ex) + { + Log?.LogError("Could not inspect the secure automatic-login credential: " + ex.Message); + ClearSavedLogin(); + return false; + } + } + + private static void StoreRefresh(TokenResult result) + { + string origin = NormalizedServerOrigin(); + RefreshCredential credential = new RefreshCredential + { + version = 1, + origin = origin, + provider = result.provider, + refresh_token = result.refresh_token, + expires_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + result.refresh_expires_in + }; + RefreshCredentials.Save(origin, credential); + credential.refresh_token = null; + result.refresh_token = null; + } + + private static RefreshCredential LoadRefresh() + { + string origin = NormalizedServerOrigin(); + RefreshCredential credential = RefreshCredentials.Load(origin); + if (credential == null || credential.version != 1 || credential.origin != origin || + !ProviderEnabled(credential.provider) || string.IsNullOrEmpty(credential.refresh_token) || + credential.expires_at <= DateTimeOffset.UtcNow.ToUnixTimeSeconds()) + { + if (credential != null) + { + credential.refresh_token = null; + } + throw new InvalidDataException("saved automatic-login credential is invalid, expired, or belongs to another server"); + } + return credential; + } + + private static byte[] BuildRefreshRequest(string token) + { + if (string.IsNullOrEmpty(token)) + { + throw new InvalidDataException("refresh token is empty"); + } + foreach (char item in token) + { + bool safe = item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || + item >= '0' && item <= '9' || item == '-' || item == '_'; + if (!safe) + { + throw new InvalidDataException("refresh token contains an unexpected character"); + } + } + return Encoding.UTF8.GetBytes("{\"refresh_token\":\"" + token + "\"}"); + } + + private static void ClearSavedLogin() + { + AccessTokens.Clear(); + PlayerPrefs.SetInt("IsAutoLogin", 0); + PlayerPrefs.SetInt("StandaloneAutoLogin", 0); + PlayerPrefs.DeleteKey("AccessToken"); + DeleteCurrentRefresh(); + PlayerPrefs.Save(); + } + + private static void DeleteCurrentRefresh() + { + if (ServerRoot == null || RefreshCredentials == null || !RefreshCredentials.IsSupported) + { + return; + } + try + { + RefreshCredentials.Delete(NormalizedServerOrigin()); + } + catch (Exception ex) + { + Log?.LogWarning("Could not delete the secure automatic-login credential: " + ex.Message); + } + } + + private static bool ValidTokenResult(TokenResult result) + { + return result != null && !string.IsNullOrEmpty(result.provider) && + !string.IsNullOrEmpty(result.access_token) && result.access_expires_in > 0 && + !string.IsNullOrEmpty(result.refresh_token) && result.refresh_expires_in > 0; + } + + private static string NormalizedServerOrigin() + { + return NormalizeOrigin(ServerRoot); + } + + private static bool SameOrigin(Uri left, Uri right) + { + return string.Equals(NormalizeOrigin(left), NormalizeOrigin(right), StringComparison.Ordinal); + } + + private static string NormalizeOrigin(Uri uri) + { + if (uri == null || !uri.IsAbsoluteUri || string.IsNullOrEmpty(uri.Host)) + { + throw new InvalidOperationException("authentication server origin is unavailable"); + } + string host = uri.IdnHost.ToLowerInvariant(); + int port = uri.IsDefaultPort ? -1 : uri.Port; + UriBuilder builder = new UriBuilder(uri.Scheme.ToLowerInvariant(), host, port); + return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/'); + } + + private static void ValidateOAuthTransport(Uri uri) + { + if (uri == null || !uri.IsAbsoluteUri || + (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback))) + { + throw new InvalidOperationException("OAuth requires HTTPS except when connecting to a loopback server"); + } + } + + private static void ValidateBrowserURL(string raw) + { + if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri uri) || + (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) || + uri.UserInfo.Length != 0) + { + throw new InvalidDataException("server returned an unsafe browser login URL"); + } + } + + private static void ApplyDiscordBrand(Image box, Image logo, Image title) + { + box.color = DiscordBlurple; + logo.sprite = DiscordSymbol; + logo.color = Color.white; + logo.preserveAspect = true; + title.sprite = DiscordWordmark; + title.color = Color.white; + title.preserveAspect = true; + DisableSpriteLocalizer(logo.gameObject); + DisableSpriteLocalizer(title.gameObject); + } + + private static void DisableSpriteLocalizer(GameObject target) + { + foreach (Behaviour behaviour in target.GetComponents()) + { + if (behaviour.GetType().Name == "SpriteLocalizer") + { + behaviour.enabled = false; + } + } + } + + private static Sprite LoadSprite(string resourceName, string name) + { + using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName); + if (stream == null) + { + throw new FileNotFoundException("Embedded login asset is missing", resourceName); + } + byte[] bytes = new byte[stream.Length]; + int offset = 0; + while (offset < bytes.Length) + { + int read = stream.Read(bytes, offset, bytes.Length - offset); + if (read == 0) + { + throw new EndOfStreamException("Unexpected end of embedded login asset " + resourceName); + } + offset += read; + } + Texture2D texture = new Texture2D(2, 2, TextureFormat.RGBA32, false, false) + { + name = name, + filterMode = FilterMode.Bilinear, + wrapMode = TextureWrapMode.Clamp + }; + if (!ImageConversion.LoadImage(texture, bytes, true)) + { + UnityEngine.Object.Destroy(texture); + throw new InvalidDataException("Could not decode embedded login asset " + resourceName); + } + Sprite sprite = Sprite.Create( + texture, + new Rect(0f, 0f, texture.width, texture.height), + new Vector2(0.5f, 0.5f), + 100f); + sprite.name = name; + return sprite; + } + + private static Button FindButton(Transform root, string name) + { + Transform match = FindDescendant(root, name); + return match == null ? null : match.GetComponent