From 35140638e660390c5749e12bb3e8bcb3740e4afa Mon Sep 17 00:00:00 2001 From: Oleksii Date: Mon, 24 Aug 2026 17:04:57 -0300 Subject: [PATCH] feat(alpha): complete proof-of-play readiness program --- .env.example | 6 +- .github/workflows/ci.yml | 30 +- .gitignore | 2 + CONTRIBUTING.md | 2 +- Cargo.lock | 245 ++- Cargo.toml | 1 + README.md | 23 +- adapters/fbneo/src/lib.rs | 6 +- adapters/retroarch/Cargo.toml | 13 + adapters/retroarch/src/lib.rs | 352 +++++ apps/client/src-tauri/Cargo.lock | 1325 ++++++++++++++++- apps/client/src-tauri/Cargo.toml | 4 + .../src-tauri/src/commands/match_probe.rs | 65 +- apps/client/src-tauri/src/commands/mod.rs | 2 + apps/client/src-tauri/src/commands/process.rs | 339 ++++- apps/client/src-tauri/src/commands/runtime.rs | 60 + apps/client/src-tauri/src/commands/session.rs | 56 + apps/client/src-tauri/src/main.rs | 6 + apps/client/src-tauri/tauri.conf.json | 2 +- apps/client/src/App.tsx | 53 +- apps/client/src/lib/api.test.ts | 32 + apps/client/src/lib/api.ts | 61 +- apps/client/src/lib/match.test.ts | 14 +- apps/client/src/lib/match.ts | 13 + apps/client/src/lib/matchCoordinator.test.ts | 37 + apps/client/src/lib/matchCoordinator.ts | 72 + apps/client/src/lib/native.ts | 97 +- apps/client/src/lib/report.test.ts | 86 +- apps/client/src/lib/report.ts | 89 +- apps/client/src/lib/store.ts | 28 +- apps/client/src/lib/useLanMatchProbe.ts | 114 +- apps/client/src/lib/usePlayableMatch.ts | 132 ++ apps/client/src/routes/Match.tsx | 117 +- apps/server/Cargo.toml | 1 + apps/server/Dockerfile | 6 +- apps/server/migrations/005_match_runtime.sql | 40 + apps/server/src/auth_rate_limit.rs | 92 ++ apps/server/src/config.rs | 73 +- apps/server/src/error.rs | 12 +- apps/server/src/lib.rs | 13 + apps/server/src/routes/auth.rs | 34 +- apps/server/src/routes/rooms.rs | 376 ++++- apps/server/src/state.rs | 4 + apps/server/tests/api_flow.rs | 132 +- docker-compose.yml | 8 +- docs/ARCHITECTURE.md | 154 +- docs/IMPLEMENTATION_STATUS.md | 30 +- docs/NEXT_IMPLEMENTATIONS.md | 23 + .../0003-retroarch-native-process-netplay.md | 72 + ...04-authoritative-native-match-lifecycle.md | 33 + docs/alpha/LAN_TEST.md | 42 +- docs/alpha/MATCH_REPORT_TEMPLATE.md | 4 +- docs/alpha/RETROARCH_TEST.md | 83 ++ docs/alpha/reports/README.md | 15 +- packages/emulator-sdk/src/lib.rs | 65 +- packages/networking/Cargo.toml | 6 +- .../src/bin/opencade-alpha-summary.rs | 14 +- .../src/bin/opencade-match-probe.rs | 1 + .../src/bin/opencade-match-verify.rs | 19 +- packages/networking/src/lib.rs | 16 +- packages/networking/src/probe.rs | 56 +- packages/networking/src/relay.rs | 72 + packages/networking/src/report.rs | 411 ++++- packages/networking/tests/report_verifier.rs | 75 +- .../src/generated/AlphaEvidenceKind.ts | 6 + .../src/generated/AlphaFailureReport.ts | 22 + .../src/generated/AlphaFailureStage.ts | 13 + .../protocol/src/generated/MatchReport.ts | 2 + .../src/generated/MatchReportCompatibility.ts | 12 + .../src/generated/MatchReportTransport.ts | 2 +- packages/protocol/src/index.ts | 4 + packages/protocol/src/lib.rs | 54 + packages/protocol/src/messages.ts | 8 + packages/shared/Cargo.toml | 5 +- packages/shared/src/lib.rs | 132 +- scripts/alpha/OpenCade-Alpha.ps1 | 207 +++ scripts/alpha/README.txt | 28 + services/relay/Cargo.toml | 6 +- services/relay/Dockerfile | 9 +- services/relay/README.md | 14 +- services/relay/src/main.rs | 293 ++-- 81 files changed, 5756 insertions(+), 527 deletions(-) create mode 100644 adapters/retroarch/Cargo.toml create mode 100644 adapters/retroarch/src/lib.rs create mode 100644 apps/client/src-tauri/src/commands/runtime.rs create mode 100644 apps/client/src-tauri/src/commands/session.rs create mode 100644 apps/client/src/lib/api.test.ts create mode 100644 apps/client/src/lib/matchCoordinator.test.ts create mode 100644 apps/client/src/lib/matchCoordinator.ts create mode 100644 apps/client/src/lib/usePlayableMatch.ts create mode 100644 apps/server/migrations/005_match_runtime.sql create mode 100644 apps/server/src/auth_rate_limit.rs create mode 100644 docs/adr/0003-retroarch-native-process-netplay.md create mode 100644 docs/adr/0004-authoritative-native-match-lifecycle.md create mode 100644 docs/alpha/RETROARCH_TEST.md create mode 100644 packages/networking/src/relay.rs create mode 100644 packages/protocol/src/generated/AlphaEvidenceKind.ts create mode 100644 packages/protocol/src/generated/AlphaFailureReport.ts create mode 100644 packages/protocol/src/generated/AlphaFailureStage.ts create mode 100644 packages/protocol/src/generated/MatchReportCompatibility.ts create mode 100644 scripts/alpha/OpenCade-Alpha.ps1 create mode 100644 scripts/alpha/README.txt diff --git a/.env.example b/.env.example index 5fe6ab2..e6ee106 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,9 @@ DATABASE_URL=postgres://opencade:opencade@db:5432/opencade # Generate with: openssl rand -hex 32 SESSION_SECRET=replace-with-at-least-32-random-bytes -ALLOWED_ORIGINS=http://localhost:1420,tauri://localhost +# Generate independently with: openssl rand -hex 32 +RELAY_AUTH_SECRET=replace-with-a-different-32-byte-random-secret +ALLOWED_ORIGINS=http://localhost:1420,tauri://localhost,http://tauri.localhost,https://tauri.localhost OPENCADE_ENV=production # Optional overrides: @@ -15,4 +17,4 @@ OPENCADE_ENV=production # POSTGRES_DB=opencade # RFC 8489 server as a numeric IP:port. Both variables should identify the same service. # OPENCADE_STUN_SERVER=203.0.113.10:3478 -# VITE_STUN_SERVER=203.0.113.10:3478 +# RELAY_URL=wss://relay.example.com/relay diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70bd52f..2d3f90f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,7 @@ jobs: run: docker compose config env: SESSION_SECRET: ci-compose-secret-at-least-32-bytes + RELAY_AUTH_SECRET: ci-relay-secret-at-least-32-bytes-long desktop: name: Desktop Rust (Windows / MSRV) @@ -149,15 +150,34 @@ jobs: - name: Build Windows LAN alpha tools run: cargo build -p opencade-networking --bins --release --locked + - name: Build Windows desktop alpha + run: cargo build --manifest-path apps/client/src-tauri/Cargo.toml --release --locked + + - name: Package and verify Windows alpha kit + shell: pwsh + run: .\scripts\alpha\OpenCade-Alpha.ps1 -Mode Package -KitRoot alpha-kit + + - name: Alpha kit verifier rejects tampering + shell: pwsh + run: | + Copy-Item alpha-kit alpha-kit-tampered -Recurse + Add-Content -LiteralPath alpha-kit-tampered\opencade-client.exe -Value "tampered" + $failedClosed = $false + try { + .\scripts\alpha\OpenCade-Alpha.ps1 -Mode Verify -KitRoot alpha-kit-tampered + } catch { + $failedClosed = $true + } + if (-not $failedClosed) { + throw "Alpha kit verifier accepted a tampered executable" + } + - name: Upload Windows LAN alpha tools if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@v4 with: - name: opencade-lan-tools-windows - path: | - target/release/opencade-match-probe.exe - target/release/opencade-match-verify.exe - target/release/opencade-alpha-summary.exe + name: opencade-windows-alpha + path: alpha-kit if-no-files-found: error retention-days: 14 diff --git a/.gitignore b/.gitignore index d10f9c4..69eedf2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ apps/client/src-tauri/target/ apps/client/src-tauri/gen/schemas/ apps/client/dist/ apps/client/bundle/ +/alpha-kit/ +/alpha-kit-tampered/ # ── Environment / Secrets ──────────────────────────────────── .env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcb26b1..0bb1bdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,7 +88,7 @@ opencade/ │ ├── fbneo/ # FBNeo adapter (first adapter) │ └── mame/ # (future) ├── services/ -│ └── relay/ # opencade-relay (TURN-like fallback) +│ └── relay/ # authenticated readiness-probe WebSocket fallback ├── research/ # NOT SHIPPED — observations only (see GUARDRAILS.md) ├── docs/ ├── docker/ diff --git a/Cargo.lock b/Cargo.lock index 8575d25..48a0a21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,16 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -397,7 +407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -407,7 +417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1002,7 +1012,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1011,7 +1021,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1038,6 +1048,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "opencade-adapter-retroarch" +version = "0.1.0" +dependencies = [ + "opencade-emulator-sdk", + "serde", + "sha2 0.11.0", +] + [[package]] name = "opencade-emulator-sdk" version = "0.1.0" @@ -1063,12 +1082,16 @@ name = "opencade-networking" version = "0.1.0" dependencies = [ "chrono", + "futures-util", "opencade-emulator-sdk", "opencade-protocol", + "opencade-shared", "serde", "serde_json", "thiserror", "tokio", + "tokio-tungstenite 0.30.0", + "url", "uuid", ] @@ -1091,9 +1114,11 @@ dependencies = [ "dashmap", "futures", "opencade-protocol", + "opencade-shared", "serde", "serde_json", "tokio", + "tokio-tungstenite 0.30.0", "tracing", "tracing-subscriber", "uuid", @@ -1110,6 +1135,7 @@ dependencies = [ "futures-util", "hex", "opencade-protocol", + "opencade-shared", "rand_core 0.6.4", "regex", "serde", @@ -1130,10 +1156,19 @@ dependencies = [ name = "opencade-shared" version = "0.1.0" dependencies = [ + "hex", + "hmac", "serde", + "sha2 0.11.0", "thiserror", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking" version = "2.2.1" @@ -1333,6 +1368,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1343,7 +1392,52 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1358,12 +1452,44 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -1530,7 +1656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1793,7 +1919,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1873,7 +1999,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1887,6 +2013,16 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -1918,7 +2054,11 @@ checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.30.0", ] @@ -2133,6 +2273,8 @@ dependencies = [ "httparse", "log", "rand 0.10.2", + "rustls", + "rustls-pki-types", "sha1 0.11.0", "thiserror", ] @@ -2170,6 +2312,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2290,7 +2438,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2352,6 +2500,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2361,6 +2518,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.4" @@ -2443,6 +2664,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 7b53925..57503f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "packages/shared", "packages/game-definitions", "adapters/fbneo", + "adapters/retroarch", "services/relay", ] resolver = "2" diff --git a/README.md b/README.md index 78e3fa5..33e54cf 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ > **Open-source arcade netplay — a clean-room, community-owned alternative for low-latency rollback matchmaking and emulation.** -OpenCade is a monorepo for a modern arcade netplay platform: Rust server (Axum + PostgreSQL), Tauri + React + TypeScript desktop client, and a pluggable emulator adapter SDK. The repository now contains an executable **Proof of Match** control plane, deterministic mock-adapter data plane, safe local FBNeo launch boundary, direct UDP transport, and an evidence-gated STUN/hole-punch path. FBNeo netplay, physical cross-NAT results, behavior classification, and relay fallback remain explicitly unproven and are not advertised as implemented. +OpenCade is a monorepo for a modern arcade netplay platform: Rust server (Axum + PostgreSQL), Tauri + React + TypeScript desktop client, and a pluggable emulator adapter SDK. The repository contains an executable **Proof of Match** control plane, deterministic mock-adapter data plane, safe local FBNeo launch, direct UDP/STUN/hole-punching, authenticated readiness-probe relay fallback, and an experimental native-process RetroArch adapter. Standalone FBNeo netplay, physical cross-NAT results, behavior classification, and real two-machine RetroArch play remain explicitly unproven. ## Support — keep it community-owned -OpenCade exists to replace proprietary Fightcade with a community-owned, self-hostable, and fully auditable alternative. Proof-of-Match is done — lobby, signaling, deterministic transport, STUN discovery, and bounded hole punching run today. What remains is physical network evidence, relay fallback, and the emulator seam — expensive, unglamorous systems work that determines whether matches actually connect. +OpenCade exists to replace proprietary Fightcade with a community-owned, self-hostable, and fully auditable alternative. Proof-of-Match is done — lobby, signaling, deterministic transport, STUN discovery, bounded hole punching, authenticated relay fallback, and an experimental user-supplied RetroArch process boundary run today. What remains is physical two-Windows Proof-of-Play evidence and production release hardening. **[☕ Buy Me a Coffee — https://buymeacoffee.com/zendevve](https://buymeacoffee.com/zendevve)** — one coffee keeps the relay and adapter work open. No paywall, no premium. @@ -40,7 +40,7 @@ curl http://localhost:8080/health docker compose logs -f opencade-server ``` -This starts `opencade-server` and PostgreSQL. The server applies committed SQLx migrations before it begins serving. A relay service is intentionally deferred. +This starts `opencade-server`, PostgreSQL, and the authenticated readiness-probe relay. The server applies committed SQLx migrations before it begins serving. ```bash # stop @@ -90,8 +90,10 @@ OpenCade/ │ ├── networking/ # Bounded input frames, deterministic in-memory + direct UDP transports │ └── shared/ # Cross-cutting utils, logging, config ├── adapters/ -│ └── fbneo/ # FBNeo reference adapter (first implementation) -├── services/ # Reserved for a future relay service +│ ├── fbneo/ # Standalone FBNeo local-play adapter +│ └── retroarch/ # Experimental native-process netplay adapter +├── services/ # Authenticated bounded readiness-probe relay +├── scripts/alpha/ # Windows alpha-kit packager, verifier, doctor, and launcher ├── research/ # OBSERVATIONS ONLY — never shipped (see Clean-Room Notice) │ ├── observations/ # Dated, factual notes from black-box behavior │ ├── protocol/ # Captured message field notes (no replay) @@ -141,11 +143,12 @@ High-level: `Client (Tauri)` ↔ `Server (Axum REST + authenticated WebSocket)` - **Server:** auth (Argon2id), hashed sessions, games, server hints, lobbies, durable challenges, rooms/matches, and authenticated WebSocket signaling (`offer`/`answer`/`candidate`). -- **Networking:** deterministic in-memory and connected UDP transports, RFC 8489 reflexive-address - discovery, and bounded nonce-bound hole punching are implemented. Cone/symmetric behavior - classification and relay fallback remain deferred pending physical campaign evidence. +- **Networking:** deterministic in-memory, direct UDP, RFC 8489 discovery, nonce-bound hole punching, + and a short-lived room-member-ticket WebSocket relay are implemented. Physical cross-NAT evidence + and cone/symmetric behavior classification remain pending. - **Client:** login/register, games, lobby challenges, match state, local availability scan, diagnostics, and redacted report export; Rust core owns process spawn, filesystem validation, and diagnostics. -- **Emulator SDK:** trait-based adapters with safe process launch (no shell injection, argument escaping), ROM validation, and game-definition TOML. +- **Emulator SDK:** explicit OpenCade/native/blocked netplay modes, safe process launch, ROM validation, + game-definition TOML, and an experimental user-supplied RetroArch + FBNeo-core adapter. Full reference: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**. @@ -161,7 +164,7 @@ Full reference: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**. | **M3** | Client Shell | Tauri + React shell, routing (Games/Lobbies/Friends/Servers/Settings), Rust fs/process/logging | Client launches, talks to server, diagnostics panel | | **M4** | Emulator SDK | Adapter trait (`detect`/`validate`/`getVersion`/`launch`/`stop`/`configure`/`getSupportedGames`), FBNeo adapter, TOML game definitions | Local ROM scan + safe launch for one title | | **M5** | Matchmaking | Lobbies, game versions, server browser, matchmaking & room lifecycle | Create/join/spectate room e2e with two peers | -| **M6** | NAT & Relay | STUN, hole-punching, `opencade-relay` TURN fallback, RTT/loss/jitter, Network Test | Direct + relayed matches measured; relay Docker image | +| **M6** | NAT & Relay | STUN, hole-punching, authenticated `opencade-relay` WebSocket fallback, RTT/loss/jitter, Network Test | Direct + relayed matches measured; relay Docker image | | **M7** | MVP Release | Hardening, bans/reports, replay hooks, packaging, docs | Tagged MVP, signed artifacts, no proprietary content | --- diff --git a/adapters/fbneo/src/lib.rs b/adapters/fbneo/src/lib.rs index 0b85150..0f1969f 100644 --- a/adapters/fbneo/src/lib.rs +++ b/adapters/fbneo/src/lib.rs @@ -1,5 +1,5 @@ use opencade_emulator_sdk::{ - AdapterCapabilities, AdapterError, DetectedEmulator, EmulatorAdapter, NetplayReadiness, + AdapterCapabilities, AdapterError, DetectedEmulator, EmulatorAdapter, NetplayMode, StdProcessLauncher, ValidationReport, spawn_validated, }; use std::path::{Path, PathBuf}; @@ -45,7 +45,7 @@ impl EmulatorAdapter for FbneoAdapter { fn capabilities(&self) -> AdapterCapabilities { AdapterCapabilities { local_play: true, - netplay: NetplayReadiness::BlockedNoPublicInterface, + netplay: NetplayMode::BlockedNoPublicInterface, } } @@ -158,7 +158,7 @@ mod tests { ); assert_eq!( adapter.capabilities().netplay, - NetplayReadiness::BlockedNoPublicInterface + NetplayMode::BlockedNoPublicInterface ); assert!(!adapter.capabilities().supports_netplay()); std::fs::remove_dir_all(root).expect("remove fixture"); diff --git a/adapters/retroarch/Cargo.toml b/adapters/retroarch/Cargo.toml new file mode 100644 index 0000000..3814e13 --- /dev/null +++ b/adapters/retroarch/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "opencade-adapter-retroarch" +version = "0.1.0" +edition = "2024" +description = "OpenCade process-boundary adapter for user-supplied RetroArch and FBNeo core" +license = "Apache-2.0" +repository = "https://github.com/opencade/opencade" +rust-version = "1.98" + +[dependencies] +opencade-emulator-sdk = { path = "../../packages/emulator-sdk" } +serde = { version = "1.0.229", features = ["derive"] } +sha2 = "0.11.0" diff --git a/adapters/retroarch/src/lib.rs b/adapters/retroarch/src/lib.rs new file mode 100644 index 0000000..63b2804 --- /dev/null +++ b/adapters/retroarch/src/lib.rs @@ -0,0 +1,352 @@ +use opencade_emulator_sdk::{ + AdapterCapabilities, AdapterError, DetectedEmulator, EmulatorAdapter, LaunchSpec, + MatchDescriptor, NetplayMode, PeerRole, ProcessLauncher, StdProcessLauncher, ValidationReport, + canonicalize_below, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::ffi::OsString; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Child; + +const MAX_HASHED_FILE_BYTES: u64 = 512 * 1024 * 1024; + +#[cfg(target_os = "windows")] +const EXECUTABLE: &str = "retroarch.exe"; +#[cfg(target_os = "macos")] +const EXECUTABLE: &str = "retroarch"; +#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] +const EXECUTABLE: &str = "retroarch"; + +#[cfg(target_os = "windows")] +const FBNEO_CORE: &str = "fbneo_libretro.dll"; +#[cfg(target_os = "macos")] +const FBNEO_CORE: &str = "fbneo_libretro.dylib"; +#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] +const FBNEO_CORE: &str = "fbneo_libretro.so"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CompatibilityFingerprint { + pub retroarch_version: Option, + pub executable_sha256: String, + pub core_sha256: String, + pub content_sha256: String, +} + +#[derive(Debug, Clone)] +pub struct RetroarchAdapter { + install_root: PathBuf, +} + +impl RetroarchAdapter { + pub fn new(install_root: impl Into) -> Self { + Self { + install_root: install_root.into(), + } + } + + pub fn rom_root(&self) -> PathBuf { + self.install_root.join("ROMs") + } + + fn executable(&self) -> PathBuf { + self.install_root.join(EXECUTABLE) + } + + fn core(&self) -> PathBuf { + self.install_root.join("cores").join(FBNEO_CORE) + } + + fn installed_version(&self) -> Option { + std::fs::read_to_string(self.install_root.join("VERSION.txt")) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + } + + pub fn fingerprint(&self, rom_path: &Path) -> Result { + let executable = canonicalize_below(&self.executable(), &self.install_root)?; + let core = canonicalize_below(&self.core(), &self.install_root)?; + let rom = canonicalize_below(rom_path, &self.rom_root())?; + Ok(CompatibilityFingerprint { + retroarch_version: self.installed_version(), + executable_sha256: sha256_file(&executable)?, + core_sha256: sha256_file(&core)?, + content_sha256: sha256_file(&rom)?, + }) + } + + pub fn match_launch_spec( + &self, + rom_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + self.prepare_match(descriptor)?; + let transport = descriptor.transport_lease()?; + self.validate(rom_path)?; + let executable = canonicalize_below(&self.executable(), &self.install_root)?; + let core = canonicalize_below(&self.core(), &self.install_root)?; + let rom = canonicalize_below(rom_path, &self.rom_root())?; + let current_dir = executable + .parent() + .ok_or_else(|| AdapterError::Validation("RetroArch has no parent directory".into()))? + .to_path_buf(); + + let mut args = vec![OsString::from("-L"), core.into_os_string()]; + match descriptor.role { + PeerRole::Host => args.push(OsString::from("--host")), + PeerRole::Guest => { + args.push(OsString::from("--connect")); + args.push(OsString::from(transport.peer_endpoint.ip().to_string())); + } + } + let netplay_port = match descriptor.role { + PeerRole::Host => transport.local_endpoint.port(), + PeerRole::Guest => transport.peer_endpoint.port(), + }; + args.extend([ + OsString::from("--port"), + OsString::from(netplay_port.to_string()), + OsString::from("--frames"), + OsString::from(descriptor.input_delay_frames.to_string()), + OsString::from("--verbose"), + rom.into_os_string(), + ]); + Ok(LaunchSpec { + executable, + current_dir, + args, + }) + } + + pub fn launch_match_with( + &self, + launcher: &L, + rom_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + launcher.spawn(&self.match_launch_spec(rom_path, descriptor)?) + } +} + +impl EmulatorAdapter for RetroarchAdapter { + fn id(&self) -> &str { + "retroarch_fbneo" + } + + fn capabilities(&self) -> AdapterCapabilities { + AdapterCapabilities { + local_play: true, + netplay: NetplayMode::NativeProcess, + } + } + + fn detect(&self, install_dir: &Path) -> Result { + let executable = install_dir.join(EXECUTABLE); + let core = install_dir.join("cores").join(FBNEO_CORE); + if !executable.is_file() || !core.is_file() { + return Err(AdapterError::NotDetected(format!( + "expected {EXECUTABLE} and cores/{FBNEO_CORE} below the RetroArch root" + ))); + } + Ok(DetectedEmulator { + id: self.id().into(), + executable: executable.canonicalize().map_err(AdapterError::Io)?, + install_root: install_dir.canonicalize().map_err(AdapterError::Io)?, + version: self.installed_version(), + }) + } + + fn validate(&self, rom_path: &Path) -> Result { + self.detect(&self.install_root)?; + canonicalize_below(rom_path, &self.rom_root())?; + if rom_path.extension().and_then(|value| value.to_str()) != Some("zip") { + return Err(AdapterError::Validation( + "RetroArch FBNeo content must be a .zip file".into(), + )); + } + let mut report = ValidationReport::valid(); + if self.installed_version().is_none() { + report.warnings.push( + "VERSION.txt is missing; exact RetroArch version will not appear in evidence" + .into(), + ); + } + Ok(report) + } + + fn get_version(&self) -> Result { + self.installed_version() + .ok_or_else(|| AdapterError::NotDetected("VERSION.txt not found".into())) + } + + fn launch(&self, rom_path: &Path) -> Result { + self.validate(rom_path)?; + let executable = canonicalize_below(&self.executable(), &self.install_root)?; + let core = canonicalize_below(&self.core(), &self.install_root)?; + let rom = canonicalize_below(rom_path, &self.rom_root())?; + let current_dir = executable + .parent() + .ok_or_else(|| AdapterError::Validation("RetroArch has no parent directory".into()))? + .to_path_buf(); + let spec = LaunchSpec { + executable, + current_dir, + args: vec![ + OsString::from("-L"), + core.into_os_string(), + OsString::from("--verbose"), + rom.into_os_string(), + ], + }; + StdProcessLauncher.spawn(&spec) + } + + fn launch_match( + &self, + rom_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + self.launch_match_with(&StdProcessLauncher, rom_path, descriptor) + } + + fn stop(&self, child: &mut Child) -> Result<(), AdapterError> { + child.kill().map_err(AdapterError::Io)?; + child.wait().map_err(AdapterError::Io)?; + Ok(()) + } +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).map_err(AdapterError::Io)?; + let length = file.metadata().map_err(AdapterError::Io)?.len(); + if length > MAX_HASHED_FILE_BYTES { + return Err(AdapterError::Validation( + "compatibility input exceeds 512 MiB hashing limit".into(), + )); + } + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(AdapterError::Io)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}") + .map_err(|_| AdapterError::Validation("failed to encode compatibility hash".into()))?; + } + Ok(encoded) +} + +#[cfg(test)] +mod tests { + use super::*; + use opencade_emulator_sdk::{TransportKind, ValidationReport}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + fn fixture() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "opencade-retroarch-{}-{}", + std::process::id(), + FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(root.join("cores")).expect("core directory"); + std::fs::create_dir_all(root.join("ROMs")).expect("ROM directory"); + std::fs::write(root.join(EXECUTABLE), b"retroarch").expect("executable"); + std::fs::write(root.join("cores").join(FBNEO_CORE), b"fbneo-core").expect("core"); + std::fs::write(root.join("ROMs/sfiii3.zip"), b"content").expect("content"); + std::fs::write(root.join("VERSION.txt"), "1.22.0\n").expect("version"); + root + } + + fn descriptor(role: PeerRole) -> MatchDescriptor { + MatchDescriptor { + room_id: "room-1".into(), + game_id: "sfiii3".into(), + local_user_id: "user-a".into(), + peer_user_id: "user-b".into(), + role, + transport: TransportKind::DirectUdp, + local_endpoint: "192.168.1.10:42000".parse().expect("endpoint"), + peer_endpoint: "192.168.1.11:42001".parse().expect("endpoint"), + input_delay_frames: 2, + } + } + + #[test] + fn detects_native_netplay_capability_and_fingerprints_inputs() { + let root = fixture(); + let adapter = RetroarchAdapter::new(&root); + assert_eq!(adapter.capabilities().netplay, NetplayMode::NativeProcess); + assert_eq!( + adapter + .validate(&root.join("ROMs/sfiii3.zip")) + .expect("valid"), + ValidationReport::valid() + ); + let fingerprint = adapter + .fingerprint(&root.join("ROMs/sfiii3.zip")) + .expect("fingerprint"); + assert_eq!(fingerprint.retroarch_version.as_deref(), Some("1.22.0")); + assert_eq!(fingerprint.executable_sha256.len(), 64); + assert_eq!(fingerprint.core_sha256.len(), 64); + assert_eq!(fingerprint.content_sha256.len(), 64); + std::fs::remove_dir_all(root).expect("remove fixture"); + } + + #[test] + fn builds_host_and_guest_arguments_without_a_shell() { + let root = fixture(); + let adapter = RetroarchAdapter::new(&root); + let host = adapter + .match_launch_spec(&root.join("ROMs/sfiii3.zip"), &descriptor(PeerRole::Host)) + .expect("host spec"); + assert!(host.args.iter().any(|arg| arg == "--host")); + assert!(host.args.iter().any(|arg| arg == "42000")); + + let guest = adapter + .match_launch_spec(&root.join("ROMs/sfiii3.zip"), &descriptor(PeerRole::Guest)) + .expect("guest spec"); + let args = guest + .args + .iter() + .map(|arg| arg.to_string_lossy()) + .collect::>(); + assert!( + args.windows(2) + .any(|pair| pair == ["--connect", "192.168.1.11"]) + ); + assert_eq!( + guest.args.last(), + Some( + &root + .join("ROMs/sfiii3.zip") + .canonicalize() + .expect("rom") + .into_os_string() + ) + ); + std::fs::remove_dir_all(root).expect("remove fixture"); + } + + #[test] + fn rejects_content_outside_the_rom_root() { + let root = fixture(); + let outside = root.parent().expect("parent").join("outside-retroarch.zip"); + std::fs::write(&outside, b"outside").expect("outside content"); + let adapter = RetroarchAdapter::new(&root); + assert!(adapter.fingerprint(&outside).is_err()); + std::fs::remove_file(outside).expect("remove outside content"); + std::fs::remove_dir_all(root).expect("remove fixture"); + } +} diff --git a/apps/client/src-tauri/Cargo.lock b/apps/client/src-tauri/Cargo.lock index 381b5f5..7ab1985 100644 --- a/apps/client/src-tauri/Cargo.lock +++ b/apps/client/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -47,6 +58,148 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "atk" version = "0.18.2" @@ -82,6 +235,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.21.7" @@ -133,6 +309,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -142,6 +336,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -266,6 +473,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.4" @@ -273,6 +489,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -309,6 +527,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -323,6 +558,31 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -333,6 +593,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.2" @@ -392,6 +667,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -426,6 +710,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -465,6 +758,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.23.0" @@ -499,6 +801,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -577,8 +885,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -734,6 +1055,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -761,6 +1109,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -850,6 +1218,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -857,6 +1231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -882,6 +1257,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1037,8 +1425,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1060,8 +1450,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -1236,6 +1629,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1243,22 +1642,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "html5ever" -version = "0.38.0" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "log", - "markup5ever", + "hmac 0.12.1", ] [[package]] -name = "http" -version = "1.5.0" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "bytes", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", "itoa", ] @@ -1291,6 +1717,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1311,6 +1746,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1510,6 +1960,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -1614,6 +2074,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1642,6 +2132,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -1686,6 +2186,27 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1750,6 +2271,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1771,6 +2298,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -1893,12 +2426,75 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2140,14 +2736,27 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "opencade-adapter-retroarch" +version = "0.1.0" +dependencies = [ + "opencade-emulator-sdk", + "serde", + "sha2 0.11.0", +] + [[package]] name = "opencade-client" version = "0.1.0" dependencies = [ + "keyring", "opencade-adapter-fbneo", + "opencade-adapter-retroarch", "opencade-emulator-sdk", "opencade-networking", "opencade-protocol", + "opencade-shared", + "reqwest", "serde", "serde_json", "tauri", @@ -2171,12 +2780,16 @@ name = "opencade-networking" version = "0.1.0" dependencies = [ "chrono", + "futures-util", "opencade-emulator-sdk", "opencade-protocol", + "opencade-shared", "serde", "serde_json", "thiserror 2.0.20", "tokio", + "tokio-tungstenite", + "url", "uuid", ] @@ -2191,12 +2804,39 @@ dependencies = [ "uuid", ] +[[package]] +name = "opencade-shared" +version = "0.1.0" +dependencies = [ + "hex", + "hmac 0.13.0", + "serde", + "sha2 0.11.0", + "thiserror 2.0.20", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2222,6 +2862,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2310,6 +2956,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2355,6 +3012,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2462,6 +3133,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -2483,6 +3211,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2545,66 +3299,175 @@ dependencies = [ name = "regex-automata" version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", ] [[package]] -name = "regex-syntax" -version = "0.8.11" +name = "rustls-pki-types" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] [[package]] -name = "reqwest" -version = "0.13.4" +name = "rustls-platform-verifier" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] [[package]] -name = "rustc-hash" -version = "2.1.3" +name = "rustls-platform-verifier-android" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] -name = "rustc_version" -version = "0.4.1" +name = "rustls-webpki" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ - "semver", + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2622,6 +3485,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2679,6 +3551,48 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -2867,6 +3781,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2874,8 +3799,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2909,6 +3845,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3021,6 +3973,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -3115,7 +4073,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3171,7 +4129,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3243,7 +4201,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.119", "tauri-utils", "thiserror 2.0.20", @@ -3277,7 +4235,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3300,7 +4258,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3367,6 +4325,19 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3517,6 +4488,32 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3808,6 +4805,24 @@ dependencies = [ "termcolor", ] +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3820,6 +4835,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -3873,6 +4899,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4066,6 +5098,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" @@ -4122,6 +5164,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4297,6 +5348,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -4352,6 +5416,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4573,7 +5646,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -4585,7 +5658,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.20", @@ -4643,6 +5716,87 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4664,6 +5818,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -4702,3 +5862,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/apps/client/src-tauri/Cargo.toml b/apps/client/src-tauri/Cargo.toml index 2221107..11dcf4b 100644 --- a/apps/client/src-tauri/Cargo.toml +++ b/apps/client/src-tauri/Cargo.toml @@ -18,10 +18,14 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "json", "rustls"] } +keyring = "4.1.6" opencade-adapter-fbneo = { path = "../../../adapters/fbneo" } +opencade-adapter-retroarch = { path = "../../../adapters/retroarch" } opencade-emulator-sdk = { path = "../../../packages/emulator-sdk" } opencade-networking = { path = "../../../packages/networking" } opencade-protocol = { path = "../../../packages/protocol" } +opencade-shared = { path = "../../../packages/shared" } uuid = { version = "1.25.0", features = ["v4"] } [features] diff --git a/apps/client/src-tauri/src/commands/match_probe.rs b/apps/client/src-tauri/src/commands/match_probe.rs index d0e9ad4..a65128d 100644 --- a/apps/client/src-tauri/src/commands/match_probe.rs +++ b/apps/client/src-tauri/src/commands/match_probe.rs @@ -1,9 +1,10 @@ use opencade_emulator_sdk::{MatchDescriptor, PeerRole, TransportKind}; use opencade_networking::{ - HolePunchConfig, MatchProbeConfig, MatchProbeReport, UdpPeer, discover_reflexive_address, - punch_hole, run_match_probe, + HolePunchConfig, MatchProbeConfig, MatchProbeReport, RelayPeer, UdpPeer, + discover_reflexive_address, punch_hole, run_match_probe, run_relay_match_probe, }; use opencade_protocol::{MatchCandidateKind, NatMappingState}; +use opencade_shared::RelayTicket; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::future::Future; @@ -64,6 +65,23 @@ pub struct RunMatchProbeRequest { pub timeout_ms: u64, } +#[derive(Debug, Deserialize)] +pub struct RunRelayMatchProbeRequest { + pub relay_url: String, + pub ticket: RelayTicket, + pub room_id: String, + pub game_id: String, + pub local_user_id: String, + pub peer_user_id: String, + pub role: PeerRole, + pub local_nonce: String, + pub peer_nonce: String, + #[serde(default = "default_frame_count")] + pub frame_count: u64, + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, +} + fn default_frame_count() -> u64 { 60 } @@ -231,6 +249,49 @@ pub async fn run_reserved_match_probe( Ok(report) } +#[tauri::command] +pub async fn run_relay_match_probe_command( + request: RunRelayMatchProbeRequest, +) -> Result { + validate_room_id(&request.room_id)?; + if request.ticket.room_id != request.room_id || request.ticket.user_id != request.local_user_id + { + return Err("relay ticket does not match the local room participant".into()); + } + let session_key = combined_session_key(&request.local_nonce, &request.peer_nonce)?; + let descriptor = MatchDescriptor { + room_id: request.room_id, + game_id: request.game_id, + local_user_id: request.local_user_id, + peer_user_id: request.peer_user_id, + role: request.role, + transport: TransportKind::Relay, + local_endpoint: "0.0.0.0:0".parse().map_err(|_| "invalid relay endpoint")?, + peer_endpoint: "0.0.0.0:0".parse().map_err(|_| "invalid relay endpoint")?, + input_delay_frames: 2, + }; + let config = MatchProbeConfig::new( + descriptor, + session_key, + request.frame_count, + Duration::from_millis(request.timeout_ms), + ) + .map_err(|error| error.to_string())?; + let peer = RelayPeer::connect(&request.relay_url, &request.ticket) + .await + .map_err(|error| error.to_string())?; + let report = run_relay_match_probe(&peer, &config) + .await + .map_err(|error| error.to_string())?; + tracing::info!( + room_id = %report.room_id, + frames = report.frames_received, + elapsed_ms = report.elapsed_ms, + "relay match probe completed" + ); + Ok(report) +} + #[tauri::command] pub async fn cancel_match_probe( state: tauri::State<'_, MatchProbeState>, diff --git a/apps/client/src-tauri/src/commands/mod.rs b/apps/client/src-tauri/src/commands/mod.rs index 565bb65..57cd914 100644 --- a/apps/client/src-tauri/src/commands/mod.rs +++ b/apps/client/src-tauri/src/commands/mod.rs @@ -2,3 +2,5 @@ pub mod diag; pub mod fs; pub mod match_probe; pub mod process; +pub mod runtime; +pub mod session; diff --git a/apps/client/src-tauri/src/commands/process.rs b/apps/client/src-tauri/src/commands/process.rs index 871c727..c86c45c 100644 --- a/apps/client/src-tauri/src/commands/process.rs +++ b/apps/client/src-tauri/src/commands/process.rs @@ -1,14 +1,21 @@ use opencade_adapter_fbneo::FbneoAdapter; -use opencade_emulator_sdk::EmulatorAdapter; +use opencade_adapter_retroarch::{CompatibilityFingerprint, RetroarchAdapter}; +use opencade_emulator_sdk::{EmulatorAdapter, MatchDescriptor, PeerRole, TransportKind}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::io::Read; +use std::net::SocketAddr; use std::path::PathBuf; use std::process::Child; -use std::sync::Mutex; -use tauri::Manager; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tauri::{Emitter, Manager}; + +const RETROARCH_ALPHA_PORT: u16 = 55_435; #[derive(Default)] pub struct ProcessState { - children: Mutex>, + children: Mutex>>>, } pub fn fbneo_root(app: &tauri::AppHandle) -> Result { @@ -18,12 +25,21 @@ pub fn fbneo_root(app: &tauri::AppHandle) -> Result { .map_err(|_| "application resource directory is unavailable".into()) } -#[tauri::command] -pub fn launch_game( - app: tauri::AppHandle, - state: tauri::State<'_, ProcessState>, - game_id: String, -) -> Result { +fn retroarch_root(app: &tauri::AppHandle) -> Result { + if let Some(root) = std::env::var_os("OPENCADE_RETROARCH_ROOT") { + let root = PathBuf::from(root); + if !root.is_absolute() { + return Err("OPENCADE_RETROARCH_ROOT must be an absolute path".into()); + } + return Ok(root); + } + app.path() + .resource_dir() + .map(|root| root.join("emulator").join("retroarch")) + .map_err(|_| "application resource directory is unavailable".into()) +} + +fn validate_game_id(game_id: &str) -> Result<(), String> { if game_id.len() < 3 || game_id.len() > 20 || !game_id @@ -32,29 +48,310 @@ pub fn launch_game( { return Err("invalid game id".into()); } + Ok(()) +} + +#[derive(Debug, Deserialize)] +pub struct RetroarchMatchRequest { + api_url: String, + session_token: String, + launch_grant: String, +} + +#[derive(Debug, Deserialize)] +struct AuthorizedLaunch { + room_id: String, + game_id: String, + local_user_id: String, + peer_user_id: String, + role: PeerRole, + local_endpoint: SocketAddr, + peer_endpoint: SocketAddr, + input_delay_frames: u8, +} + +#[derive(Debug, Deserialize)] +struct ApiEnvelope { + payload: T, +} + +#[derive(Debug, Serialize)] +struct ConsumeLaunchGrant<'a> { + grant: &'a str, +} + +#[derive(Debug, Serialize)] +pub struct RetroarchMatchLaunch { + pid: u32, + adapter: &'static str, + room_id: String, + fingerprint: CompatibilityFingerprint, +} + +#[derive(Debug, Clone, Serialize)] +struct EmulatorExitEvent { + pid: u32, + room_id: Option, + exit_code: Option, + success: bool, +} + +struct NativeExitCallback { + api_url: String, + session_token: String, + room_id: String, +} + +#[derive(Serialize)] +struct FinishRoomBody { + exit_code: Option, +} + +#[tauri::command] +pub fn launch_game( + app: tauri::AppHandle, + state: tauri::State<'_, ProcessState>, + game_id: String, +) -> Result { + validate_game_id(&game_id)?; let root = fbneo_root(&app)?; let adapter = FbneoAdapter::new(&root); let child = adapter .launch(&root.join("ROMs").join(format!("{game_id}.zip"))) .map_err(|error| error.to_string())?; - let pid = child.id(); - state - .children - .lock() - .map_err(|_| "process registry unavailable".to_string())? - .insert(pid, child); - Ok(pid) + register_child(&app, &state, child, None) +} + +#[tauri::command] +pub async fn launch_retroarch_match( + app: tauri::AppHandle, + state: tauri::State<'_, ProcessState>, + request: RetroarchMatchRequest, +) -> Result { + let authorized = consume_launch_grant(&request).await?; + validate_game_id(&authorized.game_id)?; + let root = retroarch_root(&app)?; + let adapter = RetroarchAdapter::new(&root); + let rom = root + .join("ROMs") + .join(format!("{}.zip", authorized.game_id)); + let descriptor = MatchDescriptor { + room_id: authorized.room_id.clone(), + game_id: authorized.game_id, + local_user_id: authorized.local_user_id, + peer_user_id: authorized.peer_user_id, + role: authorized.role, + transport: TransportKind::DirectUdp, + local_endpoint: authorized.local_endpoint, + peer_endpoint: authorized.peer_endpoint, + input_delay_frames: authorized.input_delay_frames, + }; + descriptor + .transport_lease() + .map_err(|error| error.to_string())?; + validate_native_endpoints(&descriptor)?; + if descriptor.role == PeerRole::Host { + ensure_native_port_available(descriptor.local_endpoint)?; + } + let (fingerprint, child) = tokio::task::spawn_blocking(move || { + let fingerprint = adapter.fingerprint(&rom)?; + let child = adapter.launch_match(&rom, &descriptor)?; + Ok::<_, opencade_emulator_sdk::AdapterError>((fingerprint, child)) + }) + .await + .map_err(|_| "native launch worker failed".to_string())? + .map_err(|error| error.to_string())?; + let room_id = authorized.room_id; + let callback = NativeExitCallback { + api_url: request.api_url, + session_token: request.session_token, + room_id: room_id.clone(), + }; + let pid = register_child(&app, &state, child, Some(callback))?; + Ok(RetroarchMatchLaunch { + pid, + adapter: "retroarch_fbneo", + room_id, + fingerprint, + }) } #[tauri::command] pub fn stop_game(state: tauri::State<'_, ProcessState>, pid: u32) -> Result<(), String> { - let mut child = state + let child = state .children .lock() .map_err(|_| "process registry unavailable".to_string())? - .remove(&pid) + .get(&pid) + .cloned() .ok_or_else(|| "emulator process not found".to_string())?; - child.kill().map_err(|error| error.to_string())?; - child.wait().map_err(|error| error.to_string())?; + child + .lock() + .map_err(|_| "emulator process unavailable".to_string())? + .kill() + .map_err(|error| error.to_string())?; + Ok(()) +} + +async fn consume_launch_grant(request: &RetroarchMatchRequest) -> Result { + let base = reqwest::Url::parse(&request.api_url).map_err(|_| "OpenCade API URL is invalid")?; + if !matches!(base.scheme(), "http" | "https") + || !base.username().is_empty() + || base.password().is_some() + || base.query().is_some() + || base.fragment().is_some() + { + return Err("OpenCade API URL is not allowed".into()); + } + if base.scheme() == "http" + && !matches!(base.host_str(), Some("localhost" | "127.0.0.1" | "::1")) + { + return Err("remote OpenCade API URLs must use HTTPS".into()); + } + if request.session_token.is_empty() || request.launch_grant.is_empty() { + return Err("authenticated launch grant is required".into()); + } + let endpoint = format!( + "{}/api/v1/match-launch-grants/consume", + request.api_url.trim_end_matches('/') + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|_| "launch authorization client unavailable")?; + let response = client + .post(endpoint) + .bearer_auth(&request.session_token) + .json(&ConsumeLaunchGrant { + grant: &request.launch_grant, + }) + .send() + .await + .map_err(|_| "launch authorization server is unreachable")?; + if !response.status().is_success() { + return Err("launch grant was rejected by the server".into()); + } + response + .json::>() + .await + .map(|envelope| envelope.payload) + .map_err(|_| "launch authorization response is invalid".into()) +} + +fn ensure_native_port_available(endpoint: SocketAddr) -> Result<(), String> { + let bind_ip = if endpoint.is_ipv4() { + std::net::Ipv4Addr::UNSPECIFIED.into() + } else { + std::net::Ipv6Addr::UNSPECIFIED.into() + }; + std::net::TcpListener::bind(SocketAddr::new(bind_ip, endpoint.port())) + .map(drop) + .map_err(|_| format!("native netplay TCP port {} is unavailable", endpoint.port())) +} + +fn validate_native_endpoints(descriptor: &MatchDescriptor) -> Result<(), String> { + for endpoint in [descriptor.local_endpoint, descriptor.peer_endpoint] { + if endpoint.port() != RETROARCH_ALPHA_PORT + || endpoint.ip().is_unspecified() + || endpoint.ip().is_multicast() + { + return Err("native alpha endpoints must be unicast on TCP port 55435".into()); + } + } Ok(()) } + +fn register_child( + app: &tauri::AppHandle, + state: &ProcessState, + mut child: Child, + exit_callback: Option, +) -> Result { + if let Some(stdout) = child.stdout.take() { + drain_process_output(stdout); + } + if let Some(stderr) = child.stderr.take() { + drain_process_output(stderr); + } + let pid = child.id(); + let child = Arc::new(Mutex::new(child)); + state + .children + .lock() + .map_err(|_| "process registry unavailable".to_string())? + .insert(pid, Arc::clone(&child)); + + let app = app.clone(); + std::thread::spawn(move || { + let status = loop { + let result = child + .lock() + .map_err(|_| ()) + .and_then(|mut child| child.try_wait().map_err(|_| ())); + match result { + Ok(Some(status)) => break Some(status), + Ok(None) => std::thread::sleep(Duration::from_millis(200)), + Err(()) => break None, + } + }; + if let Ok(mut children) = app.state::().children.lock() + && children + .get(&pid) + .is_some_and(|registered| Arc::ptr_eq(registered, &child)) + { + children.remove(&pid); + } + let exit_code = status.as_ref().and_then(std::process::ExitStatus::code); + let room_id = exit_callback + .as_ref() + .map(|callback| callback.room_id.clone()); + if let Some(callback) = exit_callback { + report_native_exit(&callback, exit_code); + } + let event = EmulatorExitEvent { + pid, + room_id, + exit_code, + success: status.is_some_and(|status| status.success()), + }; + if let Err(error) = app.emit("opencade://emulator-exited", event) { + tracing::warn!(%error, pid, "failed to emit emulator exit event"); + } + }); + Ok(pid) +} + +fn report_native_exit(callback: &NativeExitCallback, exit_code: Option) { + let endpoint = format!( + "{}/api/v1/rooms/{}/finish", + callback.api_url.trim_end_matches('/'), + callback.room_id + ); + let Ok(client) = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + else { + tracing::warn!(room_id = %callback.room_id, "native exit reporter unavailable"); + return; + }; + for attempt in 1..=3 { + let result = client + .post(&endpoint) + .bearer_auth(&callback.session_token) + .json(&FinishRoomBody { exit_code }) + .send(); + if result.is_ok_and(|response| response.status().is_success()) { + return; + } + if attempt < 3 { + std::thread::sleep(Duration::from_millis(200 * attempt)); + } + } + tracing::warn!(room_id = %callback.room_id, "failed to report native process exit"); +} + +fn drain_process_output(mut reader: R) { + std::thread::spawn(move || { + let _ = std::io::copy(&mut reader, &mut std::io::sink()); + }); +} diff --git a/apps/client/src-tauri/src/commands/runtime.rs b/apps/client/src-tauri/src/commands/runtime.rs new file mode 100644 index 0000000..2be02f6 --- /dev/null +++ b/apps/client/src-tauri/src/commands/runtime.rs @@ -0,0 +1,60 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct RuntimeConfig { + api_url: String, + stun_server: Option, +} + +#[tauri::command] +pub fn runtime_config() -> Result { + let api_url = + std::env::var("OPENCADE_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); + validate_api_url(&api_url)?; + let stun_server = std::env::var("OPENCADE_STUN_SERVER") + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); + if let Some(server) = &stun_server { + server + .parse::() + .map_err(|_| "OPENCADE_STUN_SERVER must be a numeric IP:port".to_string())?; + } + Ok(RuntimeConfig { + api_url: api_url.trim_end_matches('/').to_owned(), + stun_server, + }) +} + +fn validate_api_url(value: &str) -> Result<(), String> { + let parsed = reqwest::Url::parse(value) + .map_err(|_| "OPENCADE_API_URL must be a valid http(s) URL".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err("OPENCADE_API_URL must be a credential-free http(s) origin".into()); + } + if parsed.scheme() == "http" + && !matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "::1")) + { + return Err("remote OPENCADE_API_URL values must use HTTPS".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_http_origins_and_rejects_other_schemes() { + assert!(validate_api_url("https://alpha.example").is_ok()); + assert!(validate_api_url("http://127.0.0.1:8080").is_ok()); + assert!(validate_api_url("http://192.168.1.10:8080").is_err()); + assert!(validate_api_url("file:///tmp/server").is_err()); + assert!(validate_api_url("https://bad host").is_err()); + } +} diff --git a/apps/client/src-tauri/src/commands/session.rs b/apps/client/src-tauri/src/commands/session.rs new file mode 100644 index 0000000..9298c2f --- /dev/null +++ b/apps/client/src-tauri/src/commands/session.rs @@ -0,0 +1,56 @@ +const SERVICE: &str = "com.opencade.client"; +const ACCOUNT: &str = "session-token"; + +fn entry() -> Result { + keyring::Entry::new(SERVICE, ACCOUNT) + .map_err(|_| "operating system credential storage is unavailable".to_string()) +} + +fn validate_token(token: &str) -> Result<(), String> { + if token.len() < 32 || token.len() > 512 || token.chars().any(char::is_whitespace) { + return Err("session token is invalid".into()); + } + Ok(()) +} + +#[tauri::command] +pub fn store_session_token(token: String) -> Result<(), String> { + validate_token(&token)?; + entry()? + .set_password(&token) + .map_err(|_| "could not save the session in operating system credential storage".into()) +} + +#[tauri::command] +pub fn load_session_token() -> Result, String> { + match entry()?.get_password() { + Ok(token) => { + validate_token(&token)?; + Ok(Some(token)) + } + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err("could not read the session from operating system credential storage".into()), + } +} + +#[tauri::command] +pub fn clear_session_token() -> Result<(), String> { + match entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => { + Err("could not clear the session from operating system credential storage".into()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_tokens_without_exposing_them_to_logs() { + assert!(validate_token(&"a".repeat(32)).is_ok()); + assert!(validate_token("too-short").is_err()); + assert!(validate_token(&format!("{} ", "a".repeat(32))).is_err()); + } +} diff --git a/apps/client/src-tauri/src/main.rs b/apps/client/src-tauri/src/main.rs index cfa2a0a..f3ae482 100644 --- a/apps/client/src-tauri/src/main.rs +++ b/apps/client/src-tauri/src/main.rs @@ -10,11 +10,17 @@ fn main() { .invoke_handler(tauri::generate_handler![ commands::fs::scan_game, commands::process::launch_game, + commands::process::launch_retroarch_match, commands::process::stop_game, commands::diag::network_test, commands::match_probe::reserve_match_probe, commands::match_probe::run_reserved_match_probe, + commands::match_probe::run_relay_match_probe_command, commands::match_probe::cancel_match_probe, + commands::runtime::runtime_config, + commands::session::store_session_token, + commands::session::load_session_token, + commands::session::clear_session_token, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/client/src-tauri/tauri.conf.json b/apps/client/src-tauri/tauri.conf.json index 9dabd42..ab322ac 100644 --- a/apps/client/src-tauri/tauri.conf.json +++ b/apps/client/src-tauri/tauri.conf.json @@ -33,7 +33,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost 'self' http://localhost:8080 ws://localhost:8080; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" + "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" } } } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 3abfd99..5112a3b 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -6,9 +6,9 @@ import type { MatchProbeCompletedPayload, } from "@opencade/protocol"; import DiagnosticsButton from "./components/DiagnosticsButton"; -import { ApiError, api } from "./lib/api"; +import { ApiError, api, configureApiBase } from "./lib/api"; import { parseMatchCompletion, parseMatchEndpoint } from "./lib/match"; -import { cancelMatchProbe } from "./lib/native"; +import { cancelMatchProbe, loadRuntimeConfig } from "./lib/native"; import { useSessionStore } from "./lib/store"; import { OpenCadeSocket, type ConnectionState } from "./lib/ws"; import Auth from "./routes/Auth"; @@ -16,25 +16,47 @@ import Games from "./routes/Games"; import Lobby from "./routes/Lobby"; import Match from "./routes/Match"; -const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080"; type View = { name: "games" } | { name: "lobby"; gameId: string } | { name: "match"; roomId: string }; export default function App() { - const { token, user, setSession, clearSession } = useSessionStore(); + const { token, user, hydrated, hydrate, setSession, clearSession } = useSessionStore(); const queryClient = useQueryClient(); const [view, setView] = useState({ name: "games" }); const [connection, setConnection] = useState("idle"); const [activeSocket, setActiveSocket] = useState(null); const [peerEndpoint, setPeerEndpoint] = useState(null); const [peerCompletion, setPeerCompletion] = useState(null); + const [apiUrl, setApiUrl] = useState(null); + const [runtimeError, setRuntimeError] = useState(null); + + useEffect(() => { + void hydrate(); + }, [hydrate]); + + useEffect(() => { + let cancelled = false; + void loadRuntimeConfig() + .then((config) => { + configureApiBase(config.api_url); + if (!cancelled) setApiUrl(config.api_url); + }) + .catch((error: unknown) => { + if (!cancelled) { + setRuntimeError(error instanceof Error ? error.message : "Runtime configuration failed"); + } + }); + return () => { + cancelled = true; + }; + }, []); const me = useQuery({ queryKey: ["me", token], queryFn: () => { if (!token) throw new Error("session token unavailable"); return api.me(token); }, - enabled: Boolean(token && !user), + enabled: Boolean(apiUrl && token && !user), retry: false, }); @@ -44,8 +66,8 @@ export default function App() { }, [token, me.data, me.error, setSession, clearSession]); useEffect(() => { - if (!token) return; - const socket = new OpenCadeSocket(API_URL, token, setConnection); + if (!apiUrl || !token) return; + const socket = new OpenCadeSocket(apiUrl, token, setConnection); setActiveSocket(socket); const unsubscribe = socket.subscribe((message: Envelope) => { if (message.type.startsWith("challenge.")) { @@ -72,7 +94,22 @@ export default function App() { socket.close(); setActiveSocket((current) => (current === socket ? null : current)); }; - }, [token, queryClient]); + }, [apiUrl, token, queryClient]); + + if (runtimeError) { + return ( +
+
{runtimeError}
+
+ ); + } + if (!apiUrl || !hydrated) { + return ( +
+
Restoring secure runtime state…
+
+ ); + } if (!token) return ; if (!user) { diff --git a/apps/client/src/lib/api.test.ts b/apps/client/src/lib/api.test.ts new file mode 100644 index 0000000..42624a8 --- /dev/null +++ b/apps/client/src/lib/api.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api, configureApiBase } from "./api.js"; + +describe("runtime API configuration", () => { + afterEach(() => { + configureApiBase("http://localhost:8080"); + vi.unstubAllGlobals(); + }); + + it("routes requests through a validated runtime origin", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ type: "games.list", payload: { games: [] } }), + }); + vi.stubGlobal("fetch", fetchMock); + configureApiBase("https://alpha.example.com/"); + + await api.games("session-token"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://alpha.example.com/api/v1/games", + expect.objectContaining({ headers: expect.any(Headers) }) + ); + }); + + it("rejects non-http origins and embedded credentials", () => { + expect(() => configureApiBase("file:///tmp/server")).toThrow(/HTTP or HTTPS/); + expect(() => configureApiBase("https://user:pass@example.com")).toThrow(/credentials/); + expect(() => configureApiBase("http://192.168.1.10:8080")).toThrow(/must use HTTPS/); + }); +}); diff --git a/apps/client/src/lib/api.ts b/apps/client/src/lib/api.ts index a1f7127..a00935a 100644 --- a/apps/client/src/lib/api.ts +++ b/apps/client/src/lib/api.ts @@ -21,6 +21,16 @@ export type Challenge = { challenged_id: string; state: "pending" | "accepted" | "declined" | "cancelled"; }; +export type RelayTicket = { + relay_url: string; + ticket: { + room_id: string; + user_id: string; + expires_at: number; + signature: string; + }; +}; +export type MatchLaunchGrant = { grant: string; expires_at: string }; type ErrorPayload = { code?: string; message?: string }; @@ -35,7 +45,28 @@ export class ApiError extends Error { } } -const API_BASE = import.meta.env.VITE_API_URL?.replace(/\/$/, "") ?? "http://localhost:8080"; +let apiBase = import.meta.env.VITE_API_URL?.replace(/\/$/, "") ?? "http://localhost:8080"; + +export function configureApiBase(url: string): void { + const parsed = new URL(url); + if (!(["http:", "https:"] as string[]).includes(parsed.protocol)) { + throw new Error("OpenCade API URL must use HTTP or HTTPS"); + } + if ( + parsed.protocol === "http:" && + !["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) + ) { + throw new Error("Remote OpenCade API URLs must use HTTPS"); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error("OpenCade API URL must not contain credentials, a query, or a fragment"); + } + apiBase = parsed.toString().replace(/\/$/, ""); +} + +export function getApiBase(): string { + return apiBase; +} async function request(path: string, token?: string | null, init?: RequestInit): Promise { const headers = new Headers(init?.headers); @@ -45,7 +76,7 @@ async function request(path: string, token?: string | null, init?: RequestIni let response: Response; try { - response = await fetch(`${API_BASE}${path}`, { ...init, headers }); + response = await fetch(`${apiBase}${path}`, { ...init, headers }); } catch { throw new ApiError(0, "network_unavailable", "OpenCade server is unreachable"); } @@ -114,6 +145,28 @@ export const api = { room: (token: string, roomId: string) => request(`/api/v1/rooms/${roomId}`, token), startRoom: (token: string, roomId: string) => request(`/api/v1/rooms/${roomId}/start`, token, post()), - finishRoom: (token: string, roomId: string) => - request(`/api/v1/rooms/${roomId}/finish`, token, post()), + finishRoom: (token: string, roomId: string, exitCode?: number | null) => + request( + `/api/v1/rooms/${roomId}/finish`, + token, + post({ exit_code: exitCode ?? null }) + ), + createLaunchGrant: ( + token: string, + roomId: string, + localEndpoint: string, + peerEndpoint: string, + inputDelayFrames = 2 + ) => + request( + `/api/v1/rooms/${roomId}/launch-grant`, + token, + post({ + local_endpoint: localEndpoint, + peer_endpoint: peerEndpoint, + input_delay_frames: inputDelayFrames, + }) + ), + relayTicket: (token: string, roomId: string) => + request(`/api/v1/rooms/${roomId}/relay-ticket`, token, post()), }; diff --git a/apps/client/src/lib/match.test.ts b/apps/client/src/lib/match.test.ts index 6665218..8402929 100644 --- a/apps/client/src/lib/match.test.ts +++ b/apps/client/src/lib/match.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { RoomPayload } from "@opencade/protocol"; -import { matchParticipants, parseMatchCompletion, parseMatchEndpoint } from "./match"; +import { + matchParticipants, + nativeLanEndpoint, + parseMatchCompletion, + parseMatchEndpoint, +} from "./match"; const room = (guestId: string | null = "guest"): RoomPayload => ({ id: "room-1", @@ -94,3 +99,10 @@ describe("parseMatchCompletion", () => { expect(parseMatchCompletion({ room_id: "room-1", frames_received: "60" })).toBeUndefined(); }); }); + +describe("nativeLanEndpoint", () => { + it("preserves the verified host and selects the RetroArch TCP port", () => { + expect(nativeLanEndpoint("192.168.1.20:42000")).toBe("192.168.1.20:55435"); + expect(nativeLanEndpoint("[::1]:42000")).toBe("[::1]:55435"); + }); +}); diff --git a/apps/client/src/lib/match.ts b/apps/client/src/lib/match.ts index 406cf61..faa9bd6 100644 --- a/apps/client/src/lib/match.ts +++ b/apps/client/src/lib/match.ts @@ -59,3 +59,16 @@ export function matchParticipants( if (room.guest_id === localUserId) return { role: "guest", peerUserId: room.host_id }; return undefined; } + +export function nativeLanEndpoint(endpoint: string, port = 55_435): string { + const parsed = new URL(`udp://${endpoint}`); + if (!parsed.hostname || !Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("Invalid native LAN endpoint"); + } + const host = parsed.hostname.startsWith("[") + ? parsed.hostname + : parsed.hostname.includes(":") + ? `[${parsed.hostname}]` + : parsed.hostname; + return `${host}:${port}`; +} diff --git a/apps/client/src/lib/matchCoordinator.test.ts b/apps/client/src/lib/matchCoordinator.test.ts new file mode 100644 index 0000000..4ae004e --- /dev/null +++ b/apps/client/src/lib/matchCoordinator.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { initialMatchCoordinatorState, transitionMatchCoordinator } from "./matchCoordinator.js"; + +describe("match coordinator", () => { + it("allows a direct verified match to become playable only after native spawn and peer launch", () => { + let state = transitionMatchCoordinator(initialMatchCoordinatorState, { + type: "probe_verified", + transport: "direct_udp", + candidate: "host", + }); + expect(state.phase).toBe("awaiting_peer"); + state = transitionMatchCoordinator(state, { type: "peer_transcript_verified" }); + state = transitionMatchCoordinator(state, { type: "launch_requested" }); + state = transitionMatchCoordinator(state, { type: "native_spawned" }); + expect(state.phase).toBe("awaiting_peer_launch"); + expect(transitionMatchCoordinator(state, { type: "room_playing" }).phase).toBe("playing"); + }); + + it("truthfully gates a relay probe from native launch", () => { + const state = transitionMatchCoordinator(initialMatchCoordinatorState, { + type: "probe_verified", + transport: "relay", + candidate: "host", + }); + expect(state.phase).toBe("relay_probe_only"); + expect(transitionMatchCoordinator(state, { type: "launch_requested" })).toEqual(state); + }); + + it("does not claim a UDP reflexive candidate is a native TCP route", () => { + const state = transitionMatchCoordinator(initialMatchCoordinatorState, { + type: "probe_verified", + transport: "direct_udp", + candidate: "reflexive", + }); + expect(state.phase).toBe("relay_probe_only"); + }); +}); diff --git a/apps/client/src/lib/matchCoordinator.ts b/apps/client/src/lib/matchCoordinator.ts new file mode 100644 index 0000000..c370683 --- /dev/null +++ b/apps/client/src/lib/matchCoordinator.ts @@ -0,0 +1,72 @@ +export type MatchPhase = + | "probing" + | "awaiting_peer" + | "ready" + | "relay_probe_only" + | "launching" + | "awaiting_peer_launch" + | "playing" + | "finished" + | "failed"; + +export type MatchCoordinatorState = { + phase: MatchPhase; + transport?: "direct_udp" | "relay"; + error?: string; +}; + +export type MatchCoordinatorEvent = + | { + type: "probe_verified"; + transport: "direct_udp" | "relay"; + candidate: "host" | "reflexive"; + } + | { type: "peer_transcript_verified" } + | { type: "launch_requested" } + | { type: "native_spawned" } + | { type: "room_playing" } + | { type: "native_exited" } + | { type: "room_finished" } + | { type: "failed"; error: string } + | { type: "reset" }; + +export const initialMatchCoordinatorState: MatchCoordinatorState = { phase: "probing" }; + +export function transitionMatchCoordinator( + state: MatchCoordinatorState, + event: MatchCoordinatorEvent +): MatchCoordinatorState { + if (event.type === "reset") return initialMatchCoordinatorState; + if (event.type === "failed") return { ...state, phase: "failed", error: event.error }; + if (event.type === "room_finished") return { ...state, phase: "finished" }; + + switch (state.phase) { + case "probing": + if (event.type === "probe_verified") { + return { + phase: + event.transport === "direct_udp" && event.candidate === "host" + ? "awaiting_peer" + : "relay_probe_only", + transport: event.transport, + }; + } + return state; + case "awaiting_peer": + return event.type === "peer_transcript_verified" ? { ...state, phase: "ready" } : state; + case "ready": + return event.type === "launch_requested" ? { ...state, phase: "launching" } : state; + case "launching": + return event.type === "native_spawned" ? { ...state, phase: "awaiting_peer_launch" } : state; + case "awaiting_peer_launch": + if (event.type === "room_playing") return { ...state, phase: "playing" }; + if (event.type === "native_exited") return { ...state, phase: "finished" }; + return state; + case "playing": + return event.type === "native_exited" ? { ...state, phase: "finished" } : state; + case "relay_probe_only": + case "finished": + case "failed": + return state; + } +} diff --git a/apps/client/src/lib/native.ts b/apps/client/src/lib/native.ts index 25a4e3b..1edb632 100644 --- a/apps/client/src/lib/native.ts +++ b/apps/client/src/lib/native.ts @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; export type GameAvailability = { game_id: string; available: boolean; warnings: string[] }; export type NetworkDiagnostics = { @@ -17,7 +18,7 @@ export type MatchProbeReport = { local_user_id: string; peer_user_id: string; role: "host" | "guest"; - transport: "direct_udp"; + transport: "direct_udp" | "relay"; frames_sent: number; frames_received: number; transcript_checksum: string; @@ -26,11 +27,43 @@ export type MatchProbeReport = { candidate: "host" | "reflexive"; punch_attempts: number; }; +export type RetroarchMatchLaunch = { + pid: number; + adapter: "retroarch_fbneo"; + room_id: string; + fingerprint: { + retroarch_version?: string | null; + executable_sha256: string; + core_sha256: string; + content_sha256: string; + }; +}; +export type EmulatorExitEvent = { + pid: number; + room_id?: string | null; + exit_code?: number | null; + success: boolean; +}; +export type RuntimeConfig = { api_url: string; stun_server?: string | null }; + +let runtimeStunServer = import.meta.env.VITE_STUN_SERVER || undefined; export function isDesktopRuntime(): boolean { return "__TAURI_IPC__" in window; } +export async function loadRuntimeConfig(): Promise { + if (!isDesktopRuntime()) { + return { + api_url: import.meta.env.VITE_API_URL ?? "http://localhost:8080", + stun_server: runtimeStunServer, + }; + } + const config = await invoke("runtime_config"); + runtimeStunServer = config.stun_server ?? undefined; + return config; +} + export async function scanGame(gameId: string): Promise { if (!isDesktopRuntime()) { return { game_id: gameId, available: false, warnings: ["Desktop scan unavailable"] }; @@ -48,6 +81,44 @@ export async function stopGame(pid: number): Promise { await invoke("stop_game", { pid }); } +export async function launchRetroarchMatch(request: { + api_url: string; + session_token: string; + launch_grant: string; +}): Promise { + if (!isDesktopRuntime()) throw new Error("RetroArch netplay requires the desktop client"); + return invoke("launch_retroarch_match", { request }); +} + +export async function onEmulatorExit( + handler: (event: EmulatorExitEvent) => void +): Promise { + if (!isDesktopRuntime()) return () => undefined; + return listen("opencade://emulator-exited", (event) => handler(event.payload)); +} + +const WEB_SESSION_KEY = "opencade.session_token"; + +export async function storeSessionToken(token: string): Promise { + if (isDesktopRuntime()) { + await invoke("store_session_token", { token }); + return; + } + sessionStorage.setItem(WEB_SESSION_KEY, token); +} + +export async function loadSessionToken(): Promise { + localStorage.removeItem(WEB_SESSION_KEY); + if (isDesktopRuntime()) return invoke("load_session_token"); + return sessionStorage.getItem(WEB_SESSION_KEY); +} + +export async function clearStoredSessionToken(): Promise { + if (isDesktopRuntime()) await invoke("clear_session_token"); + else sessionStorage.removeItem(WEB_SESSION_KEY); + localStorage.removeItem(WEB_SESSION_KEY); +} + export async function runNetworkTest(): Promise { if (!isDesktopRuntime()) { return { nat: "unknown", rtt_ms: null, relay_reachable: false }; @@ -60,7 +131,7 @@ export async function reserveMatchProbe(roomId: string): Promise("reserve_match_probe", { request: { room_id: roomId, - stun_server: import.meta.env.VITE_STUN_SERVER || undefined, + stun_server: runtimeStunServer, }, }); } @@ -81,6 +152,28 @@ export async function runReservedMatchProbe(request: { return invoke("run_reserved_match_probe", { request }); } +export async function runRelayMatchProbe(request: { + relay_url: string; + ticket: { + room_id: string; + user_id: string; + expires_at: number; + signature: string; + }; + room_id: string; + game_id: string; + local_user_id: string; + peer_user_id: string; + role: "host" | "guest"; + local_nonce: string; + peer_nonce: string; + frame_count?: number; + timeout_ms?: number; +}): Promise { + if (!isDesktopRuntime()) throw new Error("Relay match probe requires the desktop client"); + return invoke("run_relay_match_probe_command", { request }); +} + export async function cancelMatchProbe(roomId: string): Promise { if (!isDesktopRuntime()) return; await invoke("cancel_match_probe", { roomId }); diff --git a/apps/client/src/lib/report.test.ts b/apps/client/src/lib/report.test.ts index c5d81e3..2ae0845 100644 --- a/apps/client/src/lib/report.test.ts +++ b/apps/client/src/lib/report.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildMatchReport } from "./report.js"; +import { buildAlphaFailureReport, buildMatchReport } from "./report.js"; describe("buildMatchReport", () => { const probe = { @@ -68,4 +68,88 @@ describe("buildMatchReport", () => { expect(JSON.stringify(report.probe)).not.toContain("nonce"); expect(JSON.stringify(report.probe)).not.toContain("endpoint"); }); + + it("records relay evidence without inventing direct candidate data", () => { + const report = buildMatchReport( + { + id: "room-123", + game_id: "sfiii3", + host_id: "host", + guest_id: "guest", + state: "finished", + }, + { ...probe, transport: "relay" }, + new Date("2026-08-23T12:00:00Z") + ); + + expect(report.probe.transport).toBe("relay"); + expect(report.probe.candidate).toBeNull(); + expect(report.probe.punch_attempts).toBeNull(); + }); + + it("adds compatibility hashes without exposing emulator paths", () => { + const report = buildMatchReport( + { + id: "room-123", + game_id: "sfiii3", + host_id: "host", + guest_id: "guest", + state: "finished", + }, + probe, + new Date("2026-08-23T12:00:00Z"), + { + adapter: "retroarch_fbneo", + emulator_version: "1.22.0", + executable_sha256: "a".repeat(64), + core_sha256: "b".repeat(64), + content_sha256: "c".repeat(64), + } + ); + + expect(report.compatibility?.adapter).toBe("retroarch_fbneo"); + expect(JSON.stringify(report.compatibility)).not.toContain("C:\\"); + expect(JSON.stringify(report.compatibility)).not.toContain("path"); + }); +}); + +describe("buildAlphaFailureReport", () => { + const room = { + id: "room-failed", + game_id: "sfiii3", + host_id: "host", + guest_id: "guest", + state: "connecting" as const, + }; + + it("records a stable failure stage without identities or diagnostic messages", () => { + const report = buildAlphaFailureReport( + room, + "host", + { + stage: "relay", + error_code: "relay_probe_failed", + transport: "relay", + }, + new Date("2026-08-24T12:00:00Z") + ); + const serialized = JSON.stringify(report); + + expect(report.kind).toBe("attempt_failure"); + expect(report.stage).toBe("relay"); + expect(report.transport).toBe("relay"); + expect(serialized).not.toContain("host_id"); + expect(serialized).not.toContain("guest_id"); + expect(serialized).not.toContain("endpoint"); + expect(serialized).not.toContain("C:\\"); + }); + + it("rejects free-form text that could leak local diagnostics", () => { + expect(() => + buildAlphaFailureReport(room, "host", { + stage: "native_launch", + error_code: "Failed at C:\\Users\\tester", + }) + ).toThrow("stable error code"); + }); }); diff --git a/apps/client/src/lib/report.ts b/apps/client/src/lib/report.ts index 0559f91..e3bcce3 100644 --- a/apps/client/src/lib/report.ts +++ b/apps/client/src/lib/report.ts @@ -1,10 +1,19 @@ -import type { MatchReport, RoomPayload } from "@opencade/protocol"; -import type { MatchProbeReport } from "./native"; +import type { + AlphaFailureReport, + AlphaFailureStage, + MatchReport, + MatchReportCompatibility, + MatchReportRole, + MatchReportTransport, + RoomPayload, +} from "@opencade/protocol"; +import type { MatchProbeReport, RetroarchMatchLaunch } from "./native"; export function buildMatchReport( room: RoomPayload, probe: MatchProbeReport, - now = new Date() + now = new Date(), + compatibility?: MatchReportCompatibility ): MatchReport { return { schema_version: 1, @@ -18,9 +27,61 @@ export function buildMatchReport( transcript_checksum: probe.transcript_checksum, elapsed_ms: probe.elapsed_ms, nat: probe.nat, - candidate: probe.candidate, - punch_attempts: probe.punch_attempts, + candidate: probe.transport === "relay" ? null : probe.candidate, + punch_attempts: probe.transport === "relay" ? null : probe.punch_attempts, + }, + client: { + platform: typeof navigator === "undefined" ? "unknown" : navigator.platform, + user_agent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent, }, + compatibility: compatibility ?? null, + }; +} + +export function downloadMatchReport( + room: RoomPayload, + probe: MatchProbeReport, + playable?: RetroarchMatchLaunch +): void { + const compatibility: MatchReportCompatibility | undefined = playable + ? { + adapter: playable.adapter, + emulator_version: playable.fingerprint.retroarch_version ?? null, + executable_sha256: playable.fingerprint.executable_sha256, + core_sha256: playable.fingerprint.core_sha256, + content_sha256: playable.fingerprint.content_sha256, + } + : undefined; + downloadJson( + `opencade-match-${room.id.slice(0, 8)}.json`, + buildMatchReport(room, probe, new Date(), compatibility) + ); +} + +export type AlphaFailureEvidence = { + stage: AlphaFailureStage; + error_code: string; + transport?: MatchReportTransport; +}; + +export function buildAlphaFailureReport( + room: RoomPayload, + role: MatchReportRole, + failure: AlphaFailureEvidence, + now = new Date() +): AlphaFailureReport { + if (!/^[a-z0-9_]{3,64}$/.test(failure.error_code)) { + throw new Error("Failure evidence requires a stable error code"); + } + return { + schema_version: 1, + kind: "attempt_failure", + exported_at: now.toISOString(), + room: { id: room.id, game_id: room.game_id, state: room.state }, + role, + stage: failure.stage, + error_code: failure.error_code, + transport: failure.transport ?? null, client: { platform: typeof navigator === "undefined" ? "unknown" : navigator.platform, user_agent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent, @@ -28,14 +89,24 @@ export function buildMatchReport( }; } -export function downloadMatchReport(room: RoomPayload, probe: MatchProbeReport): void { - const report = buildMatchReport(room, probe, new Date()); +export function downloadAlphaFailureReport( + room: RoomPayload, + role: MatchReportRole, + failure: AlphaFailureEvidence +): void { + downloadJson( + `opencade-failure-${room.id.slice(0, 8)}.json`, + buildAlphaFailureReport(room, role, failure) + ); +} + +function downloadJson(filename: string, value: unknown): void { const url = URL.createObjectURL( - new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }) + new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }) ); const link = document.createElement("a"); link.href = url; - link.download = `opencade-match-${room.id.slice(0, 8)}.json`; + link.download = filename; link.click(); URL.revokeObjectURL(url); } diff --git a/apps/client/src/lib/store.ts b/apps/client/src/lib/store.ts index 2459f00..1ffc9f8 100644 --- a/apps/client/src/lib/store.ts +++ b/apps/client/src/lib/store.ts @@ -1,33 +1,35 @@ import { create } from "zustand"; +import { clearStoredSessionToken, loadSessionToken, storeSessionToken } from "./native"; export type SessionUser = { id: string; username: string; email?: string | null }; type SessionState = { token: string | null; user: SessionUser | null; + hydrated: boolean; + hydrate: () => Promise; setSession: (token: string, user: SessionUser) => void; clearSession: () => void; }; -const TOKEN_KEY = "opencade.session_token"; - -function storedToken(): string | null { - try { - return localStorage.getItem(TOKEN_KEY); - } catch { - return null; - } -} - export const useSessionStore = create((set) => ({ - token: storedToken(), + token: null, user: null, + hydrated: false, + hydrate: async () => { + try { + const token = await loadSessionToken(); + set({ token, hydrated: true }); + } catch { + set({ token: null, user: null, hydrated: true }); + } + }, setSession: (token, user) => { - localStorage.setItem(TOKEN_KEY, token); set({ token, user }); + void storeSessionToken(token); }, clearSession: () => { - localStorage.removeItem(TOKEN_KEY); set({ token: null, user: null }); + void clearStoredSessionToken(); }, })); diff --git a/apps/client/src/lib/useLanMatchProbe.ts b/apps/client/src/lib/useLanMatchProbe.ts index 60c472e..1b3a6d8 100644 --- a/apps/client/src/lib/useLanMatchProbe.ts +++ b/apps/client/src/lib/useLanMatchProbe.ts @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import type { + AlphaFailureStage, MatchEndpointPayload, MatchProbeCompletedPayload, + MatchReportTransport, RoomPayload, } from "@opencade/protocol"; import { api } from "./api"; @@ -10,6 +11,7 @@ import { matchParticipants } from "./match"; import { cancelMatchProbe, reserveMatchProbe, + runRelayMatchProbe, runReservedMatchProbe, type MatchEndpointCandidate, type MatchProbeReport, @@ -27,6 +29,12 @@ type LanMatchProbeOptions = { onRetry: () => void; }; +export type ProbeFailureEvidence = { + stage: AlphaFailureStage; + error_code: string; + transport?: MatchReportTransport; +}; + export function useLanMatchProbe({ token, userId, @@ -37,14 +45,13 @@ export function useLanMatchProbe({ peerCompletion, onRetry, }: LanMatchProbeOptions) { - const queryClient = useQueryClient(); const [localEndpoint, setLocalEndpoint] = useState(null); const [probeReport, setProbeReport] = useState(null); const [probeError, setProbeError] = useState(null); + const [probeFailure, setProbeFailure] = useState(null); const [candidateRelayed, setCandidateRelayed] = useState(false); const [isResetting, setIsResetting] = useState(false); const probeStarted = useRef(false); - const roomAdvanced = useRef(false); const resetStarted = useRef(false); const probeAttempt = useRef(0); const preparationGeneration = useRef(0); @@ -92,6 +99,10 @@ export function useLanMatchProbe({ } catch (error) { if (!cancelled && probeAttempt.current === attempt) { setProbeError(errorMessage(error, "Failed to reserve LAN probe")); + setProbeFailure({ + stage: "endpoint_reservation", + error_code: "endpoint_reservation_failed", + }); } } }; @@ -121,17 +132,63 @@ export function useLanMatchProbe({ let cancelled = false; const run = async () => { try { - const report = await runReservedMatchProbe({ - room_id: roomId, - game_id: room.game_id, - local_user_id: userId, - peer_user_id: participants.peerUserId, - role: participants.role, - peer_endpoint: peerEndpoint.endpoint, - peer_reflexive_endpoint: peerEndpoint.reflexive_endpoint ?? undefined, - peer_nonce: peerEndpoint.nonce, - }); + let report: MatchProbeReport; + try { + report = await runReservedMatchProbe({ + room_id: roomId, + game_id: room.game_id, + local_user_id: userId, + peer_user_id: participants.peerUserId, + role: participants.role, + peer_endpoint: peerEndpoint.endpoint, + peer_reflexive_endpoint: peerEndpoint.reflexive_endpoint ?? undefined, + peer_nonce: peerEndpoint.nonce, + }); + } catch (directError) { + if (!cancelled && probeAttempt.current === attempt) { + setProbeError( + `${errorMessage(directError, "Direct UDP failed")}; trying authenticated relay` + ); + } + let relay: Awaited>; + try { + relay = await api.relayTicket(token, roomId); + } catch (error) { + if (!cancelled && probeAttempt.current === attempt) { + setProbeFailure({ + stage: "relay_ticket", + error_code: "relay_ticket_failed", + transport: "relay", + }); + } + throw error; + } + try { + report = await runRelayMatchProbe({ + relay_url: relay.relay_url, + ticket: relay.ticket, + room_id: roomId, + game_id: room.game_id, + local_user_id: userId, + peer_user_id: participants.peerUserId, + role: participants.role, + local_nonce: localEndpoint.nonce, + peer_nonce: peerEndpoint.nonce, + }); + } catch (error) { + if (!cancelled && probeAttempt.current === attempt) { + setProbeFailure({ + stage: "relay", + error_code: "relay_probe_failed", + transport: "relay", + }); + } + throw error; + } + } if (cancelled || probeAttempt.current !== attempt) return; + setProbeFailure(null); + setProbeError(null); setProbeReport(report); await relayUntilDelivered( socket, @@ -156,32 +213,7 @@ export function useLanMatchProbe({ return () => { cancelled = true; }; - }, [candidateRelayed, localEndpoint, peerEndpoint, room, roomId, socket, userId]); - - useEffect(() => { - if (!probeReport || !peerCompletion || !room || roomAdvanced.current) return; - const participants = matchParticipants(room, userId); - if (!participants || participants.role !== "host") return; - if ( - peerCompletion.frames_received !== probeReport.frames_received || - peerCompletion.transcript_checksum !== probeReport.transcript_checksum - ) { - setProbeError("Peer transcript does not match the local LAN probe"); - return; - } - roomAdvanced.current = true; - const advance = async () => { - try { - await api.startRoom(token, roomId); - await api.finishRoom(token, roomId); - await queryClient.invalidateQueries({ queryKey: ["room", roomId] }); - } catch (error) { - roomAdvanced.current = false; - setProbeError(errorMessage(error, "Failed to finish match room")); - } - }; - void advance(); - }, [peerCompletion, probeReport, queryClient, room, roomId, token, userId]); + }, [candidateRelayed, localEndpoint, peerEndpoint, room, roomId, socket, token, userId]); const retry = async () => { if (resetStarted.current) return; @@ -192,8 +224,8 @@ export function useLanMatchProbe({ await reservationInFlight.current?.catch(() => undefined); await cancelMatchProbe(roomId); probeStarted.current = false; - roomAdvanced.current = false; setProbeError(null); + setProbeFailure(null); setProbeReport(null); setCandidateRelayed(false); setLocalEndpoint(null); @@ -206,7 +238,7 @@ export function useLanMatchProbe({ } }; - return { localEndpoint, probeReport, probeError, isResetting, retry }; + return { localEndpoint, probeReport, probeError, probeFailure, isResetting, retry }; } async function relayUntilDelivered( diff --git a/apps/client/src/lib/usePlayableMatch.ts b/apps/client/src/lib/usePlayableMatch.ts new file mode 100644 index 0000000..794da39 --- /dev/null +++ b/apps/client/src/lib/usePlayableMatch.ts @@ -0,0 +1,132 @@ +import { useEffect, useReducer } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { + MatchEndpointPayload, + MatchProbeCompletedPayload, + RoomPayload, +} from "@opencade/protocol"; +import { api, getApiBase } from "./api"; +import { matchParticipants, nativeLanEndpoint } from "./match"; +import { initialMatchCoordinatorState, transitionMatchCoordinator } from "./matchCoordinator"; +import { + launchRetroarchMatch, + onEmulatorExit, + stopGame, + type MatchEndpointCandidate, + type MatchProbeReport, +} from "./native"; + +type PlayableMatchOptions = { + token: string; + userId: string; + roomId: string; + room?: RoomPayload; + localEndpoint: MatchEndpointCandidate | null; + peerEndpoint?: MatchEndpointPayload; + probeReport: MatchProbeReport | null; + peerCompletion?: MatchProbeCompletedPayload; +}; + +export function usePlayableMatch({ + token, + userId, + roomId, + room, + localEndpoint, + peerEndpoint, + probeReport, + peerCompletion, +}: PlayableMatchOptions) { + const queryClient = useQueryClient(); + const [coordinator, dispatch] = useReducer( + transitionMatchCoordinator, + initialMatchCoordinatorState + ); + const participants = room ? matchParticipants(room, userId) : undefined; + const playableMatch = useMutation({ + mutationFn: async () => { + if (!room || !participants || !localEndpoint || !peerEndpoint) { + throw new Error("Peer session is incomplete"); + } + if (probeReport?.transport !== "direct_udp") { + throw new Error("Native gameplay requires a verified direct UDP path"); + } + dispatch({ type: "launch_requested" }); + const grant = await api.createLaunchGrant( + token, + roomId, + nativeLanEndpoint(localEndpoint.endpoint), + nativeLanEndpoint(peerEndpoint.endpoint) + ); + const launch = await launchRetroarchMatch({ + api_url: getApiBase(), + session_token: token, + launch_grant: grant.grant, + }); + try { + await api.startRoom(token, roomId); + } catch (error) { + await stopGame(launch.pid).catch(() => undefined); + throw error; + } + await queryClient.invalidateQueries({ queryKey: ["room", roomId] }); + return launch; + }, + onSuccess: () => dispatch({ type: "native_spawned" }), + onError: (error) => + dispatch({ + type: "failed", + error: error instanceof Error ? error.message : "Native launch failed", + }), + }); + + useEffect(() => { + if (!probeReport) return; + dispatch({ + type: "probe_verified", + transport: probeReport.transport, + candidate: probeReport.candidate, + }); + }, [probeReport]); + + useEffect(() => { + if (!probeReport || !peerCompletion) return; + if ( + peerCompletion.frames_received !== probeReport.frames_received || + peerCompletion.transcript_checksum !== probeReport.transcript_checksum + ) { + dispatch({ type: "failed", error: "Peer transcript does not match the local LAN probe" }); + return; + } + dispatch({ type: "peer_transcript_verified" }); + }, [peerCompletion, probeReport]); + + useEffect(() => { + if (room?.state === "playing") dispatch({ type: "room_playing" }); + if (room?.state === "finished") dispatch({ type: "room_finished" }); + }, [room?.state]); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + void onEmulatorExit((event) => { + if (event.room_id !== roomId || cancelled) return; + dispatch({ type: "native_exited" }); + void queryClient.invalidateQueries({ queryKey: ["room", roomId] }); + }).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [queryClient, roomId]); + + return { + coordinator, + participants, + playableMatch, + resetCoordinator: () => dispatch({ type: "reset" }), + }; +} diff --git a/apps/client/src/routes/Match.tsx b/apps/client/src/routes/Match.tsx index 4bc3e78..4e9a75c 100644 --- a/apps/client/src/routes/Match.tsx +++ b/apps/client/src/routes/Match.tsx @@ -1,8 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import type { MatchEndpointPayload, MatchProbeCompletedPayload } from "@opencade/protocol"; import { api } from "../lib/api"; -import { downloadMatchReport } from "../lib/report"; +import { downloadAlphaFailureReport, downloadMatchReport } from "../lib/report"; import { useLanMatchProbe } from "../lib/useLanMatchProbe"; +import { usePlayableMatch } from "../lib/usePlayableMatch"; import type { OpenCadeSocket } from "../lib/ws"; export default function Match({ @@ -29,28 +30,55 @@ export default function Match({ queryFn: () => api.room(token, roomId), refetchInterval: 2_000, }); - const { localEndpoint, probeReport, probeError, isResetting, retry } = useLanMatchProbe({ + const { localEndpoint, probeReport, probeError, probeFailure, isResetting, retry } = + useLanMatchProbe({ + token, + userId, + roomId, + room: room.data, + socket, + peerEndpoint, + peerCompletion, + onRetry: onProbeRetry, + }); + const state = room.data?.state ?? "connecting"; + const { coordinator, participants, playableMatch, resetCoordinator } = usePlayableMatch({ token, userId, roomId, room: room.data, - socket, + localEndpoint, peerEndpoint, + probeReport, peerCompletion, - onRetry: onProbeRetry, }); - const state = room.data?.state ?? "connecting"; const steps = ["connecting", "playing", "finished"]; const active = Math.max(0, steps.indexOf(state)); const heading = - state === "connecting" - ? "Establishing peer session" - : state === "playing" - ? "Match in progress" - : state === "finished" - ? "Match complete" - : `Room ${state}`; + coordinator.phase === "relay_probe_only" + ? "Relay readiness verified" + : state === "connecting" + ? "Establishing peer session" + : state === "playing" + ? "Match in progress" + : state === "finished" + ? "Match complete" + : `Room ${state}`; const completedRoom = room.data?.state === "finished" ? room.data : undefined; + const failureRoom = room.data; + const failureEvidence = playableMatch.isError + ? { + stage: "native_launch" as const, + error_code: "native_launch_failed", + transport: probeReport?.transport, + } + : coordinator.phase === "failed" && coordinator.error?.includes("transcript") + ? { + stage: "peer_transcript" as const, + error_code: "peer_transcript_mismatch", + transport: probeReport?.transport, + } + : probeFailure; return (

Room {roomId.slice(0, 8)}

@@ -74,8 +102,20 @@ export default function Match({ )} {probeReport && (

- Direct UDP verified: {probeReport.frames_received} frames in {probeReport.elapsed_ms} ms · - transcript {probeReport.transcript_checksum} + {probeReport.transport === "relay" ? "Authenticated relay" : "Direct UDP"} verified:{" "} + {probeReport.frames_received} frames in {probeReport.elapsed_ms} ms · transcript{" "} + {probeReport.transcript_checksum} +

+ )} + {coordinator.phase === "relay_probe_only" && ( +

+ The readiness probe passed, but this UDP route is not a usable RetroArch TCP route. Native + gameplay is limited to a verified same-LAN host candidate for now. +

+ )} + {coordinator.error && !playableMatch.isError && ( +

+ {coordinator.error}

)} {probeError && ( @@ -88,20 +128,61 @@ export default function Match({ {room.error.message}

)} + {playableMatch.isError && ( +

+ {playableMatch.error.message} +

+ )} + {playableMatch.data && ( +

+ RetroArch netplay launched · PID {playableMatch.data.pid} · content{" "} + {playableMatch.data.fingerprint.content_sha256.slice(0, 12)} +

+ )}
- {probeError && ( - )} {completedRoom && probeReport && ( )} + {failureRoom && participants && failureEvidence && ( + + )} + {coordinator.phase === "ready" && participants && !playableMatch.data && ( + + )} diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 225d8b3..e343c20 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -28,6 +28,7 @@ hex = "0.4.3" dashmap = "6.2.1" thiserror = "2.0.20" opencade-protocol = { path = "../../packages/protocol" } +opencade-shared = { path = "../../packages/shared" } [dev-dependencies] futures-util = { version = "0.3.34", default-features = false, features = ["sink", "std"] } diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 93dabc5..370f422 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -14,11 +14,12 @@ COPY packages/emulator-sdk/Cargo.toml packages/emulator-sdk/Cargo.toml COPY packages/shared/Cargo.toml packages/shared/Cargo.toml COPY packages/game-definitions/Cargo.toml packages/game-definitions/Cargo.toml COPY adapters/fbneo/Cargo.toml adapters/fbneo/Cargo.toml +COPY adapters/retroarch/Cargo.toml adapters/retroarch/Cargo.toml COPY services/relay/Cargo.toml services/relay/Cargo.toml # Create stub sources so `cargo build` can resolve the workspace graph. RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ - packages/emulator-sdk/src packages/shared/src packages/game-definitions/src adapters/fbneo/src services/relay/src \ + packages/emulator-sdk/src packages/shared/src packages/game-definitions/src adapters/fbneo/src adapters/retroarch/src services/relay/src \ && echo "fn main() {}" > apps/server/src/main.rs \ && echo "" > packages/protocol/src/lib.rs \ && echo "" > packages/networking/src/lib.rs \ @@ -26,6 +27,7 @@ RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ && echo "" > packages/shared/src/lib.rs \ && echo "" > packages/game-definitions/src/lib.rs \ && echo "" > adapters/fbneo/src/lib.rs \ + && echo "" > adapters/retroarch/src/lib.rs \ && echo "fn main() {}" > services/relay/src/main.rs \ && cargo build --release --locked @@ -34,7 +36,7 @@ COPY . . # Remove only the dummy workspace artifacts before compiling the real sources. Dependency # artifacts remain cached, while Cargo can no longer mistake a stub crate for the application. -RUN cargo clean --release -p opencade-server -p opencade-protocol \ +RUN cargo clean --release -p opencade-server -p opencade-protocol -p opencade-shared \ && cargo build --release --locked -p opencade-server \ && test -x target/release/opencade-server diff --git a/apps/server/migrations/005_match_runtime.sql b/apps/server/migrations/005_match_runtime.sql new file mode 100644 index 0000000..b72eed2 --- /dev/null +++ b/apps/server/migrations/005_match_runtime.sql @@ -0,0 +1,40 @@ +-- Native-process lifecycle evidence and the MVP's two-player invariant. +UPDATE rooms SET max_players = 2 WHERE max_players <> 2; +ALTER TABLE rooms DROP CONSTRAINT IF EXISTS rooms_max_players_check; +ALTER TABLE rooms + ADD CONSTRAINT rooms_max_players_check CHECK (max_players = 2); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_matches_room_unique ON matches (room_id); + +CREATE TABLE IF NOT EXISTS match_runtime_participants ( + room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + launched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, + exit_code INT, + PRIMARY KEY (room_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_match_runtime_room_ended + ON match_runtime_participants (room_id, ended_at); + +CREATE TABLE IF NOT EXISTS match_launch_grants ( + token_hash TEXT PRIMARY KEY, + room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + game_id TEXT NOT NULL REFERENCES games(id), + local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + peer_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('host', 'guest')), + local_endpoint TEXT NOT NULL, + peer_endpoint TEXT NOT NULL, + input_delay_frames SMALLINT NOT NULL CHECK (input_delay_frames BETWEEN 0 AND 15), + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_match_launch_grants_expiry + ON match_launch_grants (expires_at); + +CREATE INDEX IF NOT EXISTS idx_match_launch_grants_room_user + ON match_launch_grants (room_id, local_user_id, expires_at); diff --git a/apps/server/src/auth_rate_limit.rs b/apps/server/src/auth_rate_limit.rs new file mode 100644 index 0000000..10e92b9 --- /dev/null +++ b/apps/server/src/auth_rate_limit.rs @@ -0,0 +1,92 @@ +use dashmap::DashMap; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +const MAX_TRACKED_KEYS: usize = 4_096; +const MAX_ATTEMPTS: usize = 5; +const ATTEMPT_WINDOW: Duration = Duration::from_secs(60); + +#[derive(Debug, Default)] +pub struct AuthRateLimiter { + attempts: DashMap>, +} + +impl AuthRateLimiter { + pub fn check(&self, key: &str) -> bool { + self.check_with_limit(key, MAX_ATTEMPTS) + } + + pub fn check_with_limit(&self, key: &str, limit: usize) -> bool { + self.check_at(key, limit, Instant::now()) + } + + fn check_at(&self, key: &str, limit: usize, now: Instant) -> bool { + if limit == 0 { + return false; + } + if self.attempts.len() >= MAX_TRACKED_KEYS && !self.attempts.contains_key(key) { + self.attempts.retain(|_, attempts| { + attempts + .back() + .is_some_and(|attempt| now.saturating_duration_since(*attempt) < ATTEMPT_WINDOW) + }); + if self.attempts.len() >= MAX_TRACKED_KEYS { + return false; + } + } + + let mut attempts = self.attempts.entry(key.to_owned()).or_default(); + while attempts + .front() + .is_some_and(|attempt| now.duration_since(*attempt) >= ATTEMPT_WINDOW) + { + attempts.pop_front(); + } + if attempts.len() >= limit { + return false; + } + attempts.push_back(now); + true + } + + pub fn clear(&self, key: &str) { + self.attempts.remove(key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounds_attempts_and_recovers_after_the_window() { + let limiter = AuthRateLimiter::default(); + let start = Instant::now(); + for _ in 0..MAX_ATTEMPTS { + assert!(limiter.check_at("login:player", MAX_ATTEMPTS, start)); + } + assert!(!limiter.check_at("login:player", MAX_ATTEMPTS, start)); + assert!(limiter.check_at("login:player", MAX_ATTEMPTS, start + ATTEMPT_WINDOW)); + } + + #[test] + fn clearing_a_successful_key_resets_its_budget() { + let limiter = AuthRateLimiter::default(); + for _ in 0..MAX_ATTEMPTS { + assert!(limiter.check("register:player")); + } + limiter.clear("register:player"); + assert!(limiter.check("register:player")); + } + + #[test] + fn expired_keys_do_not_permanently_exhaust_the_registry() { + let limiter = AuthRateLimiter::default(); + let start = Instant::now(); + for key in 0..MAX_TRACKED_KEYS { + assert!(limiter.check_at(&format!("key:{key}"), 1, start)); + } + assert!(!limiter.check_at("new-key", 1, start)); + assert!(limiter.check_at("new-key", 1, start + ATTEMPT_WINDOW)); + } +} diff --git a/apps/server/src/config.rs b/apps/server/src/config.rs index 5b54682..4a5c83c 100644 --- a/apps/server/src/config.rs +++ b/apps/server/src/config.rs @@ -10,9 +10,13 @@ pub enum ConfigError { WeakProductionSecret, #[error("ALLOWED_ORIGINS must contain at least one origin")] MissingAllowedOrigins, + #[error("RELAY_URL and RELAY_AUTH_SECRET must be configured together")] + IncompleteRelayConfig, + #[error("RELAY_AUTH_SECRET must contain at least 32 characters")] + WeakRelaySecret, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct Config { pub database_url: String, pub session_secret: String, @@ -23,6 +27,28 @@ pub struct Config { pub stun_host: String, pub stun_port: u16, pub relay_url: Option, + pub relay_secret: Option, +} + +impl std::fmt::Debug for Config { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Config") + .field("database_url", &"") + .field("session_secret", &"") + .field("rust_log", &self.rust_log) + .field("port", &self.port) + .field("production", &self.production) + .field("allowed_origins", &self.allowed_origins) + .field("stun_host", &self.stun_host) + .field("stun_port", &self.stun_port) + .field("relay_url", &self.relay_url) + .field( + "relay_secret", + &self.relay_secret.as_ref().map(|_| ""), + ) + .finish() + } } impl Config { @@ -51,7 +77,10 @@ impl Config { } let allowed_origins = lookup("ALLOWED_ORIGINS") - .unwrap_or_else(|| "http://localhost:1420,tauri://localhost".to_string()) + .unwrap_or_else(|| { + "http://localhost:1420,tauri://localhost,http://tauri.localhost,https://tauri.localhost" + .to_string() + }) .split(',') .map(str::trim) .filter(|origin| !origin.is_empty()) @@ -73,6 +102,18 @@ impl Config { let relay_url = lookup("RELAY_URL") .map(|v| v.trim().to_owned()) .filter(|v| !v.is_empty()); + let relay_secret = lookup("RELAY_AUTH_SECRET") + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()); + if relay_url.is_some() != relay_secret.is_some() { + return Err(ConfigError::IncompleteRelayConfig); + } + if relay_secret + .as_ref() + .is_some_and(|secret| secret.len() < 32) + { + return Err(ConfigError::WeakRelaySecret); + } Ok(Self { database_url, @@ -84,6 +125,7 @@ impl Config { stun_host, stun_port, relay_url, + relay_secret, }) } @@ -98,6 +140,7 @@ impl Config { stun_host: "127.0.0.1".into(), stun_port: 3478, relay_url: None, + relay_secret: None, } } } @@ -118,6 +161,11 @@ mod tests { assert_eq!(config.port, 8080); assert!(!config.production); assert!(config.allowed_origins.contains(&"tauri://localhost".into())); + assert!( + config + .allowed_origins + .contains(&"https://tauri.localhost".into()) + ); assert_eq!(config.stun_host, "stun.opencade.local"); assert_eq!(config.stun_port, 3478); assert_eq!(config.relay_url, None); @@ -178,6 +226,10 @@ mod tests { ("STUN_HOST", "stun.example.com"), ("STUN_PORT", "3479"), ("RELAY_URL", "wss://relay.example.com/relay"), + ( + "RELAY_AUTH_SECRET", + "relay-test-secret-at-least-32-bytes-long", + ), ]) .expect("stun and relay should parse"); assert_eq!(config.stun_host, "stun.example.com"); @@ -192,9 +244,26 @@ mod tests { fn relay_url_absent_when_not_set_or_empty() { let config = load_config(&[]).expect("defaults"); assert_eq!(config.relay_url, None); + assert_eq!(config.relay_secret, None); let config = load_config(&[("RELAY_URL", "")]).expect("empty relay should be None"); assert_eq!(config.relay_url, None); let config = load_config(&[("RELAY_URL", " ")]).expect("whitespace relay should be None"); assert_eq!(config.relay_url, None); + assert_eq!(config.relay_secret, None); + } + + #[test] + fn rejects_incomplete_or_weak_relay_configuration() { + assert_eq!( + load_config(&[("RELAY_URL", "wss://relay.example.com/relay")]), + Err(ConfigError::IncompleteRelayConfig) + ); + assert_eq!( + load_config(&[ + ("RELAY_URL", "wss://relay.example.com/relay"), + ("RELAY_AUTH_SECRET", "weak"), + ]), + Err(ConfigError::WeakRelaySecret) + ); } } diff --git a/apps/server/src/error.rs b/apps/server/src/error.rs index 25f1c76..530cc8b 100644 --- a/apps/server/src/error.rs +++ b/apps/server/src/error.rs @@ -35,6 +35,9 @@ pub enum AppError { #[error("version unsupported: {0}")] VersionUnsupported(String), + + #[error("rate limited: {0}")] + RateLimited(String), } impl AppError { @@ -48,6 +51,7 @@ impl AppError { AppError::Conflict(_) => StatusCode::CONFLICT, AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::VersionUnsupported(_) => StatusCode::BAD_REQUEST, + AppError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS, } } @@ -61,6 +65,7 @@ impl AppError { AppError::Conflict(_) => "conflict", AppError::Internal(_) => "internal", AppError::VersionUnsupported(_) => "version_unsupported", + AppError::RateLimited(_) => "rate_limited", } } @@ -73,7 +78,8 @@ impl AppError { | AppError::NotFound(m) | AppError::Conflict(m) | AppError::Internal(m) - | AppError::VersionUnsupported(m) => m.clone(), + | AppError::VersionUnsupported(m) + | AppError::RateLimited(m) => m.clone(), } } @@ -147,6 +153,10 @@ mod tests { AppError::VersionUnsupported("x".into()).status_code(), StatusCode::BAD_REQUEST ); + assert_eq!( + AppError::RateLimited("x".into()).status_code(), + StatusCode::TOO_MANY_REQUESTS + ); } #[test] diff --git a/apps/server/src/lib.rs b/apps/server/src/lib.rs index 941fb2f..f12f4b6 100644 --- a/apps/server/src/lib.rs +++ b/apps/server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth_rate_limit; pub mod authn; pub mod config; pub mod error; @@ -67,6 +68,18 @@ pub fn build_app(state: AppState) -> Router { post(routes::rooms::cancel_room), ) .route("/api/v1/rooms/{id}/start", post(routes::rooms::start_room)) + .route( + "/api/v1/rooms/{id}/relay-ticket", + post(routes::rooms::relay_ticket), + ) + .route( + "/api/v1/rooms/{id}/launch-grant", + post(routes::rooms::create_launch_grant), + ) + .route( + "/api/v1/match-launch-grants/consume", + post(routes::rooms::consume_launch_grant), + ) .route( "/api/v1/rooms/{id}/finish", post(routes::rooms::finish_room), diff --git a/apps/server/src/routes/auth.rs b/apps/server/src/routes/auth.rs index b4578a8..324e479 100644 --- a/apps/server/src/routes/auth.rs +++ b/apps/server/src/routes/auth.rs @@ -91,6 +91,16 @@ pub async fn register( if let Some(email) = request.email.as_deref() { validate_email(email)?; } + let rate_key = format!("register:{}", request.username.trim().to_ascii_lowercase()); + if !state.auth_rate_limiter.check(&rate_key) + || !state + .auth_rate_limiter + .check_with_limit("register-global", 30) + { + return Err(AppError::RateLimited( + "too many account creation attempts; retry in one minute".into(), + )); + } let salt = SaltString::generate(&mut OsRng); let password_hash = Argon2::default() @@ -128,6 +138,7 @@ pub async fn register( .commit() .await .map_err(|error| database_error(error, "commit registration"))?; + state.auth_rate_limiter.clear(&rate_key); let user = PublicUser { id: user_id.to_string(), @@ -151,6 +162,16 @@ pub async fn login( if request.identifier.trim().is_empty() { return Err(AppError::BadRequest("identifier must not be empty".into())); } + let rate_key = format!("login:{}", request.identifier.trim().to_ascii_lowercase()); + if !state.auth_rate_limiter.check(&rate_key) + || !state + .auth_rate_limiter + .check_with_limit("login-global", 120) + { + return Err(AppError::RateLimited( + "too many sign-in attempts; retry in one minute".into(), + )); + } let row = sqlx::query( "SELECT id, username, email, password_hash @@ -161,8 +182,16 @@ pub async fn login( .bind(request.identifier.trim()) .fetch_optional(&state.pool) .await - .map_err(|error| database_error(error, "find login user"))? - .ok_or_else(|| AppError::Unauthorized("invalid credentials".into()))?; + .map_err(|error| database_error(error, "find login user"))?; + + let Some(row) = row else { + let salt = SaltString::encode_b64(b"opencade-dummy-salt") + .map_err(|_| AppError::Internal("password verifier unavailable".into()))?; + Argon2::default() + .hash_password(request.password.as_bytes(), &salt) + .map_err(|_| AppError::Internal("password verifier unavailable".into()))?; + return Err(AppError::Unauthorized("invalid credentials".into())); + }; let user_id: Uuid = row .try_get("id") @@ -181,6 +210,7 @@ pub async fn login( Argon2::default() .verify_password(request.password.as_bytes(), &parsed_hash) .map_err(|_| AppError::Unauthorized("invalid credentials".into()))?; + state.auth_rate_limiter.clear(&rate_key); let token = generate_session_token(); let expires_at = chrono::Utc::now() + chrono::Duration::days(30); diff --git a/apps/server/src/routes/rooms.rs b/apps/server/src/routes/rooms.rs index 0d2a422..ca6a8a0 100644 --- a/apps/server/src/routes/rooms.rs +++ b/apps/server/src/routes/rooms.rs @@ -2,18 +2,21 @@ use std::sync::atomic::Ordering; use axum::{Json, extract::Path, extract::State, http::StatusCode}; use opencade_protocol::{Envelope, RoomPayload, RoomState}; +use opencade_shared::RelayTicket; use serde::Deserialize; use serde_json::{Value, json}; use sqlx::{Postgres, Row, Transaction}; use uuid::Uuid; use crate::{ - authn::AuthUser, + authn::{AuthUser, generate_session_token, hash_token}, error::AppError, room_state::{RoomEvent, from_database, to_database, transition}, state::AppState, }; +const RETROARCH_ALPHA_PORT: u16 = 55_435; + #[derive(Debug, Deserialize)] pub struct CreateRoomRequest { game_id: String, @@ -21,6 +24,23 @@ pub struct CreateRoomRequest { max_players: i32, } +#[derive(Debug, Deserialize)] +pub struct FinishRoomRequest { + exit_code: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateLaunchGrantRequest { + local_endpoint: String, + peer_endpoint: String, + input_delay_frames: u8, +} + +#[derive(Debug, Deserialize)] +pub struct ConsumeLaunchGrantRequest { + grant: String, +} + fn default_max_players() -> i32 { 2 } @@ -94,9 +114,9 @@ pub async fn create_room( user: AuthUser, Json(request): Json, ) -> Result<(StatusCode, Json>), AppError> { - if request.game_id.trim().is_empty() || !(2..=4).contains(&request.max_players) { + if request.game_id.trim().is_empty() || request.max_players != 2 { return Err(AppError::BadRequest( - "game_id is required and max_players must be between 2 and 4".into(), + "game_id is required and alpha rooms support exactly two players".into(), )); } let game_exists = @@ -234,25 +254,197 @@ pub async fn start_room( user: AuthUser, Path(id): Path, ) -> Result>, AppError> { - advance_match(&state, &user, id, RoomEvent::Start).await + confirm_native_launch(&state, &user, id).await } pub async fn finish_room( State(state): State, user: AuthUser, Path(id): Path, + Json(request): Json, ) -> Result>, AppError> { - advance_match(&state, &user, id, RoomEvent::Finish).await + confirm_native_exit(&state, &user, id, request.exit_code).await } -async fn advance_match( +pub async fn relay_ticket( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> Result>, AppError> { + let relay_url = state + .config + .relay_url + .as_deref() + .ok_or_else(|| AppError::Conflict("relay fallback is not configured".into()))?; + let relay_secret = state + .config + .relay_secret + .as_deref() + .ok_or_else(|| AppError::Internal("relay signing is not configured".into()))?; + let eligible = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM room_members + JOIN rooms ON rooms.id = room_members.room_id + WHERE room_members.room_id = $1 + AND room_members.user_id = $2 + AND rooms.state IN ('CONNECTING', 'PLAYING') + )", + ) + .bind(id) + .bind(user.id) + .fetch_one(&state.pool) + .await?; + if !eligible { + return Err(AppError::Forbidden( + "relay tickets require active room membership".into(), + )); + } + let expires_at = chrono::Utc::now().timestamp() + 120; + let ticket = RelayTicket::issue( + relay_secret.as_bytes(), + &id.to_string(), + &user.id.to_string(), + expires_at, + ) + .map_err(|_| AppError::Internal("failed to issue relay ticket".into()))?; + Ok(Json(Envelope::new( + "relay.ticket", + json!({ "relay_url": relay_url, "ticket": ticket }), + ))) +} + +pub async fn create_launch_grant( + State(state): State, + user: AuthUser, + Path(id): Path, + Json(request): Json, +) -> Result>, AppError> { + let local_endpoint = request + .local_endpoint + .parse::() + .map_err(|_| AppError::BadRequest("local_endpoint must be a numeric IP:port".into()))?; + let peer_endpoint = request + .peer_endpoint + .parse::() + .map_err(|_| AppError::BadRequest("peer_endpoint must be a numeric IP:port".into()))?; + if local_endpoint.port() != RETROARCH_ALPHA_PORT + || peer_endpoint.port() != RETROARCH_ALPHA_PORT + || local_endpoint.ip().is_unspecified() + || local_endpoint.ip().is_multicast() + || peer_endpoint.ip().is_unspecified() + || peer_endpoint.ip().is_multicast() + || request.input_delay_frames > 15 + { + return Err(AppError::BadRequest( + "native alpha endpoints must be unicast on port 55435 and input delay must be at most 15" + .into(), + )); + } + + let mut transaction = state.pool.begin().await?; + let (host_id, current, _) = locked_room(&mut transaction, id).await?; + if current != RoomState::Connecting { + return Err(AppError::Conflict( + "launch grants are issued only while a room is connecting".into(), + )); + } + let row = sqlx::query( + "SELECT rooms.game_id, peer.user_id AS peer_user_id + FROM rooms + JOIN room_members local ON local.room_id = rooms.id AND local.user_id = $2 + JOIN room_members peer ON peer.room_id = rooms.id AND peer.user_id <> $2 + WHERE rooms.id = $1 + ORDER BY peer.joined_at + LIMIT 1", + ) + .bind(id) + .bind(user.id) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| AppError::Forbidden("launch grant requires two room members".into()))?; + let game_id: String = row + .try_get("game_id") + .map_err(|_| AppError::Internal("invalid room record".into()))?; + let peer_user_id: Uuid = row + .try_get("peer_user_id") + .map_err(|_| AppError::Internal("invalid room member record".into()))?; + let role = if user.id == host_id { "host" } else { "guest" }; + let grant = generate_session_token(); + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(90); + sqlx::query( + "INSERT INTO match_launch_grants ( + token_hash, room_id, game_id, local_user_id, peer_user_id, role, + local_endpoint, peer_endpoint, input_delay_frames, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + ) + .bind(hash_token(&grant)) + .bind(id) + .bind(&game_id) + .bind(user.id) + .bind(peer_user_id) + .bind(role) + .bind(local_endpoint.to_string()) + .bind(peer_endpoint.to_string()) + .bind(i16::from(request.input_delay_frames)) + .bind(expires_at) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + Ok(Json(Envelope::new( + "match.launch_grant.created", + json!({ "grant": grant, "expires_at": expires_at }), + ))) +} + +pub async fn consume_launch_grant( + State(state): State, + user: AuthUser, + Json(request): Json, +) -> Result>, AppError> { + if request.grant.len() < 32 || request.grant.len() > 256 { + return Err(AppError::BadRequest("launch grant is invalid".into())); + } + let row = sqlx::query( + "UPDATE match_launch_grants SET consumed_at = now() + WHERE token_hash = $1 AND local_user_id = $2 AND expires_at > now() + AND consumed_at IS NULL + RETURNING room_id, game_id, local_user_id, peer_user_id, role, + local_endpoint, peer_endpoint, input_delay_frames", + ) + .bind(hash_token(&request.grant)) + .bind(user.id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::Unauthorized("launch grant is invalid or expired".into()))?; + + Ok(Json(Envelope::new( + "match.launch_grant.consumed", + json!({ + "room_id": row.try_get::("room_id").map_err(|_| AppError::Internal("invalid launch grant".into()))?, + "game_id": row.try_get::("game_id").map_err(|_| AppError::Internal("invalid launch grant".into()))?, + "local_user_id": row.try_get::("local_user_id").map_err(|_| AppError::Internal("invalid launch grant".into()))?, + "peer_user_id": row.try_get::("peer_user_id").map_err(|_| AppError::Internal("invalid launch grant".into()))?, + "role": row.try_get::("role").map_err(|_| AppError::Internal("invalid launch grant".into()))?, + "local_endpoint": row.try_get::("local_endpoint").map_err(|_| AppError::Internal("invalid launch grant".into()))?, + "peer_endpoint": row.try_get::("peer_endpoint").map_err(|_| AppError::Internal("invalid launch grant".into()))?, + "input_delay_frames": row.try_get::("input_delay_frames").map_err(|_| AppError::Internal("invalid launch grant".into()))?, + }), + ))) +} + +async fn confirm_native_launch( state: &AppState, user: &AuthUser, room_id: Uuid, - event: RoomEvent, ) -> Result>, AppError> { let mut transaction = state.pool.begin().await?; let (_, current, _) = locked_room(&mut transaction, room_id).await?; + if !matches!(current, RoomState::Connecting | RoomState::Playing) { + return Err(AppError::Conflict( + "native launch confirmation requires a connecting or playing room".into(), + )); + } let member = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM room_members WHERE room_id = $1 AND user_id = $2)", ) @@ -263,36 +455,174 @@ async fn advance_match( if !member { return Err(AppError::Forbidden("not a room member".into())); } - let next = transition(current, event).map_err(|error| AppError::Conflict(error.to_string()))?; - sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") + let existing_launch = sqlx::query_scalar::<_, bool>( + "SELECT ended_at IS NOT NULL FROM match_runtime_participants + WHERE room_id = $1 AND user_id = $2", + ) + .bind(room_id) + .bind(user.id) + .fetch_optional(&mut *transaction) + .await?; + if let Some(ended) = existing_launch { + if ended { + return Err(AppError::Conflict( + "a finished native process cannot be relaunched in the same room".into(), + )); + } + transaction.commit().await?; + return notify_room_state(state, room_id, user.id).await; + } + let authorized = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM match_launch_grants + WHERE room_id = $1 AND local_user_id = $2 + AND consumed_at IS NOT NULL AND expires_at > now() + )", + ) + .bind(room_id) + .bind(user.id) + .fetch_one(&mut *transaction) + .await?; + if !authorized { + return Err(AppError::Conflict( + "native launch confirmation requires a consumed launch grant".into(), + )); + } + sqlx::query( + "INSERT INTO match_runtime_participants (room_id, user_id) + VALUES ($1, $2) + ON CONFLICT (room_id, user_id) DO NOTHING", + ) + .bind(room_id) + .bind(user.id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM match_launch_grants WHERE room_id = $1 AND local_user_id = $2") .bind(room_id) - .bind(to_database(&next)) + .bind(user.id) .execute(&mut *transaction) .await?; - match event { - RoomEvent::Start => { - sqlx::query( - "INSERT INTO matches (room_id, game_id, started_at) - SELECT id, game_id, now() FROM rooms WHERE id = $1", - ) + + let member_count = + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM room_members WHERE room_id = $1") .bind(room_id) + .fetch_one(&mut *transaction) + .await?; + let launched_count = sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM match_runtime_participants WHERE room_id = $1", + ) + .bind(room_id) + .fetch_one(&mut *transaction) + .await?; + + if current == RoomState::Connecting && member_count == 2 && launched_count == member_count { + let next = transition(current, RoomEvent::Start) + .map_err(|error| AppError::Conflict(error.to_string()))?; + sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") + .bind(room_id) + .bind(to_database(&next)) .execute(&mut *transaction) .await?; + sqlx::query( + "INSERT INTO matches (room_id, game_id, started_at) + SELECT id, game_id, now() FROM rooms WHERE id = $1 + ON CONFLICT (room_id) DO NOTHING", + ) + .bind(room_id) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + + notify_room_state(state, room_id, user.id).await +} + +async fn confirm_native_exit( + state: &AppState, + user: &AuthUser, + room_id: Uuid, + exit_code: Option, +) -> Result>, AppError> { + let mut transaction = state.pool.begin().await?; + let (_, current, _) = locked_room(&mut transaction, room_id).await?; + if !matches!( + current, + RoomState::Connecting | RoomState::Playing | RoomState::Finished | RoomState::Cancelled + ) { + return Err(AppError::Conflict( + "native exit confirmation requires an active or terminal match room".into(), + )); + } + let updated = sqlx::query( + "UPDATE match_runtime_participants + SET ended_at = COALESCE(ended_at, now()), exit_code = COALESCE(exit_code, $3) + WHERE room_id = $1 AND user_id = $2", + ) + .bind(room_id) + .bind(user.id) + .bind(exit_code) + .execute(&mut *transaction) + .await?; + if updated.rows_affected() == 0 { + return Err(AppError::Conflict( + "native exit cannot be confirmed before launch".into(), + )); + } + + match current { + RoomState::Connecting => { + let next = transition(current, RoomEvent::Cancel) + .map_err(|error| AppError::Conflict(error.to_string()))?; + sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") + .bind(room_id) + .bind(to_database(&next)) + .execute(&mut *transaction) + .await?; } - RoomEvent::Finish => { - sqlx::query( - "UPDATE matches SET ended_at = now() - WHERE room_id = $1 AND ended_at IS NULL", + RoomState::Playing => { + let member_count = sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM room_members WHERE room_id = $1", ) .bind(room_id) - .execute(&mut *transaction) + .fetch_one(&mut *transaction) + .await?; + let ended_count = sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM match_runtime_participants + WHERE room_id = $1 AND ended_at IS NOT NULL", + ) + .bind(room_id) + .fetch_one(&mut *transaction) .await?; + if member_count == 2 && ended_count == member_count { + let next = transition(current, RoomEvent::Finish) + .map_err(|error| AppError::Conflict(error.to_string()))?; + sqlx::query("UPDATE rooms SET state = $2 WHERE id = $1") + .bind(room_id) + .bind(to_database(&next)) + .execute(&mut *transaction) + .await?; + sqlx::query( + "UPDATE matches SET ended_at = COALESCE(ended_at, now()) WHERE room_id = $1", + ) + .bind(room_id) + .execute(&mut *transaction) + .await?; + } } - _ => return Err(AppError::Internal("unsupported match event".into())), + RoomState::Finished | RoomState::Cancelled => {} + _ => return Err(AppError::Internal("invalid native lifecycle state".into())), } transaction.commit().await?; - let payload = room_payload(state, room_id, user.id).await?; + notify_room_state(state, room_id, user.id).await +} + +async fn notify_room_state( + state: &AppState, + room_id: Uuid, + requesting_user: Uuid, +) -> Result>, AppError> { + let payload = room_payload(state, room_id, requesting_user).await?; let payload_value = serde_json::to_value(&payload) .map_err(|_| AppError::Internal("failed to serialize room".into()))?; let members = diff --git a/apps/server/src/state.rs b/apps/server/src/state.rs index a2fe17f..418ac88 100644 --- a/apps/server/src/state.rs +++ b/apps/server/src/state.rs @@ -12,6 +12,7 @@ use axum::extract::ws::Message; use opencade_protocol::Envelope; use serde_json::Value; +use crate::auth_rate_limit::AuthRateLimiter; use crate::config::Config; /// Counters exposed via `GET /metrics` (M7). @@ -42,6 +43,8 @@ pub struct AppState { >, /// Prometheus counters for `/metrics`. pub metrics: Arc, + + pub auth_rate_limiter: Arc, } impl AppState { @@ -54,6 +57,7 @@ impl AppState { config, ws_hub: std::sync::Arc::new(dashmap::DashMap::new()), metrics: Arc::new(Metrics::default()), + auth_rate_limiter: Arc::new(AuthRateLimiter::default()), } } diff --git a/apps/server/tests/api_flow.rs b/apps/server/tests/api_flow.rs index c9a2fee..3a7f6ed 100644 --- a/apps/server/tests/api_flow.rs +++ b/apps/server/tests/api_flow.rs @@ -6,6 +6,7 @@ use axum::{ use futures_util::{SinkExt, StreamExt}; use opencade_protocol::Envelope; use opencade_server::{AppState, Config, build_app}; +use opencade_shared::RelayTicket; use serde_json::{Value, json}; use sqlx::PgPool; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -78,7 +79,10 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { .run(&pool) .await .expect("migrations should succeed"); - let app = build_app(AppState::new(pool.clone(), Config::for_test())); + let mut config = Config::for_test(); + config.relay_url = Some("wss://relay.example/relay".into()); + config.relay_secret = Some("integration-relay-secret-at-least-32-bytes".into()); + let app = build_app(AppState::new(pool.clone(), config)); let host_token = register(&app, "host_player").await; let guest_token = register(&app, "guest_player").await; let observer_token = register(&app, "observer_player").await; @@ -89,6 +93,16 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { assert_eq!(games_status, StatusCode::OK); assert_eq!(games.payload["games"].as_array().map(Vec::len), Some(5)); + let (oversized_room_status, _) = request( + &app, + Method::POST, + "/api/v1/rooms", + Some(&host_token), + Some(json!({ "game_id": "sfiii3", "max_players": 3 })), + ) + .await; + assert_eq!(oversized_room_status, StatusCode::BAD_REQUEST); + let (_, waiting_room) = request( &app, Method::POST, @@ -169,6 +183,40 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { assert_eq!(room.payload["state"], "connecting"); assert!(room.payload["guest_id"].as_str().is_some()); + let (observer_relay_status, _) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/relay-ticket"), + Some(&observer_token), + None, + ) + .await; + assert_eq!(observer_relay_status, StatusCode::FORBIDDEN); + let (relay_status, relay_response) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/relay-ticket"), + Some(&host_token), + None, + ) + .await; + assert_eq!(relay_status, StatusCode::OK); + assert_eq!( + relay_response.payload["relay_url"], + "wss://relay.example/relay" + ); + let relay_ticket: RelayTicket = + serde_json::from_value(relay_response.payload["ticket"].clone()).expect("relay ticket"); + assert_eq!(relay_ticket.room_id, room_id); + assert!( + relay_ticket + .verify( + b"integration-relay-secret-at-least-32-bytes", + chrono::Utc::now().timestamp(), + ) + .is_ok() + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind test server"); @@ -316,6 +364,64 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { assert_eq!(completion_ack.msg_type, "match.probe.completed.relayed"); assert_eq!(completion_ack.request_id, completion_request_id); + let (grant_status, grant) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/launch-grant"), + Some(&host_token), + Some(json!({ + "local_endpoint": "192.168.1.20:55435", + "peer_endpoint": "192.168.1.21:55435", + "input_delay_frames": 2 + })), + ) + .await; + assert_eq!(grant_status, StatusCode::OK); + let raw_grant = grant.payload["grant"].as_str().expect("launch grant"); + let (consume_status, consumed) = request( + &app, + Method::POST, + "/api/v1/match-launch-grants/consume", + Some(&host_token), + Some(json!({ "grant": raw_grant })), + ) + .await; + assert_eq!(consume_status, StatusCode::OK); + assert_eq!(consumed.payload["room_id"], room_id); + assert_eq!(consumed.payload["role"], "host"); + let (replay_status, _) = request( + &app, + Method::POST, + "/api/v1/match-launch-grants/consume", + Some(&host_token), + Some(json!({ "grant": raw_grant })), + ) + .await; + assert_eq!(replay_status, StatusCode::UNAUTHORIZED); + let (guest_grant_status, guest_grant) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/launch-grant"), + Some(&guest_token), + Some(json!({ + "local_endpoint": "192.168.1.21:55435", + "peer_endpoint": "192.168.1.20:55435", + "input_delay_frames": 2 + })), + ) + .await; + assert_eq!(guest_grant_status, StatusCode::OK); + let (guest_consume_status, guest_consumed) = request( + &app, + Method::POST, + "/api/v1/match-launch-grants/consume", + Some(&guest_token), + Some(json!({ "grant": guest_grant.payload["grant"] })), + ) + .await; + assert_eq!(guest_consume_status, StatusCode::OK); + assert_eq!(guest_consumed.payload["role"], "guest"); + let (start_status, started) = request( &app, Method::POST, @@ -325,16 +431,36 @@ async fn authenticated_users_create_and_accept_a_room(pool: PgPool) { ) .await; assert_eq!(start_status, StatusCode::OK); + assert_eq!(started.payload["state"], "connecting"); + let (guest_start_status, started) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/start"), + Some(&guest_token), + None, + ) + .await; + assert_eq!(guest_start_status, StatusCode::OK); assert_eq!(started.payload["state"], "playing"); let (finish_status, finished) = request( &app, Method::POST, &format!("/api/v1/rooms/{room_id}/finish"), - Some(&guest_token), - None, + Some(&host_token), + Some(json!({ "exit_code": 0 })), ) .await; assert_eq!(finish_status, StatusCode::OK); + assert_eq!(finished.payload["state"], "playing"); + let (guest_finish_status, finished) = request( + &app, + Method::POST, + &format!("/api/v1/rooms/{room_id}/finish"), + Some(&guest_token), + Some(json!({ "exit_code": 0 })), + ) + .await; + assert_eq!(guest_finish_status, StatusCode::OK); assert_eq!(finished.payload["state"], "finished"); let ended = sqlx::query_scalar::<_, bool>( "SELECT ended_at IS NOT NULL FROM matches WHERE room_id = $1", diff --git a/docker-compose.yml b/docker-compose.yml index eb8ad09..15d0a3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,9 @@ services: SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET to at least 32 random bytes} OPENCADE_ENV: production PORT: 8080 + # Must be reachable by desktop clients; override for remote/LAN deployments. + RELAY_URL: ${RELAY_URL:-ws://localhost:8081/relay} + RELAY_AUTH_SECRET: ${RELAY_AUTH_SECRET:?set RELAY_AUTH_SECRET to at least 32 random bytes} ports: - "8080:8080" healthcheck: @@ -52,10 +55,9 @@ services: environment: RUST_LOG: info PORT: 8081 - RELAY_PORT: 3478 + RELAY_AUTH_SECRET: ${RELAY_AUTH_SECRET:?set RELAY_AUTH_SECRET to at least 32 random bytes} ports: - - "3478:3478" - - "3478:3478/udp" + - "8081:8081" volumes: pgdata: driver: local diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ca0fab..d891533 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -136,7 +136,7 @@ flowchart TB UI[React Router
Games / Lobbies / Friends
Servers / Settings] TAURI[Tauri Rust Core
process / fs / logging
permissions — no shell] SDK_RT[packages/emulator-sdk
runtime + adapter host] - ADAPTER[adapters/fbneo
FBNeo adapter] + ADAPTER[adapters/fbneo + retroarch
local + native-process adapters] NET_C[packages/networking
signaling client
NAT traversal] PROTO_C[packages/protocol
typed envelope] end @@ -151,7 +151,7 @@ flowchart TB end subgraph Relay["services/relay — opencade-relay"] - RELAY_WS[WS relay placeholder
TURN-like fallback] + RELAY_WS[authenticated WS readiness relay
signed room-member tickets] end subgraph External["External / Local"] @@ -184,10 +184,10 @@ flowchart TB OBS --- WS classDef placeholder fill:#2a2a2a,stroke:#888,stroke-dasharray: 6 4,color:#ccc - class RELAY_WS,OBS placeholder + class OBS placeholder ``` -**Data flow:** Client authenticates via REST, opens a single WS at `/ws` for presence/lobby/chat/signaling. Matchmaking creates a `room`; signaling exchanges `offer/answer/candidate` through the server (in-process WS relay in MVP). Peers then attempt direct UDP → hole-punch → STUN; on failure they fall back to WS relay (`services/relay` placeholder, promoted to TURN later). Emulator launch is local, via the SDK adapter, after the SDK validates ROM/bios presence. +**Data flow:** Client authenticates via REST and opens one room-scoped control WebSocket. Matchmaking creates a room and exchanges host/reflexive candidates. Peers attempt the reserved direct UDP socket first; a failed readiness probe requests a two-minute signed room-member ticket and uses the bounded WebSocket relay. Emulator launch remains local. Standalone FBNeo is local-play only; the experimental RetroArch adapter launches a user-supplied external process whose documented native netplay owns its own data plane. --- @@ -255,15 +255,10 @@ D:/OpenCade/ │ └── shared/ # cross-cutting TS/Rust utilities, result types │ └── src/ ├── adapters/ -│ └── fbneo/ # FBNeo adapter — first implementation -│ ├── src/ -│ │ ├── mod.rs / index.ts -│ │ ├── detect.rs -│ │ ├── validate.rs -│ │ └── launch.rs -│ └── Cargo.toml / package.json +│ ├── fbneo/ # local-launch adapter; netplay blocked without a public API +│ └── retroarch/ # user-supplied RetroArch + FBNeo-core native netplay adapter ├── services/ -│ └── relay/ # opencade-relay — placeholder (WS relay / future TURN) +│ └── relay/ # authenticated, bounded readiness-probe WebSocket relay │ ├── src/main.rs │ ├── Cargo.toml │ └── Dockerfile @@ -296,14 +291,20 @@ All paths are relative to `D:/OpenCade`. No file from `D:/Fightcade` appears in **Winner: B-Modular grafted with A and C.** - **B (modular monorepo)** provides the package boundaries (`protocol`, `emulator-sdk`, `game-definitions`, `networking`, `shared`, `adapters/fbneo`) and the `apps/*` / `services/*` split. This is the dominant structure. -- **Graft A (single Axum monolith, Postgres-only, WS in-process relay)** constrains the server: one deployable (`apps/server`), one database (PostgreSQL), no Redis in MVP, WebSocket relay lives inside the Axum process as the fallback path. Keeps ops trivial and eliminates distributed-state bugs early. -- **Graft C (Docker Compose, observability placeholder, opencade-relay placeholder)** adds the deployment and operational shell: `docker-compose.yml` at root with `db + opencade-server` active and `opencade-relay` commented as placeholder, structured logging / tracing / metrics placeholders, and a relay service stub that can be promoted to a real TURN server without re-architecture. +- **Graft A (single Axum control plane, Postgres-only)** constrains the server: one control-plane + deployable (`apps/server`), one database (PostgreSQL), and no Redis in MVP. +- **Graft C (Docker Compose, observability shell, bounded relay)** adds the deployment and + operational shell: `docker-compose.yml` runs the database, control plane, and a separate + authenticated WebSocket readiness relay. The relay is deliberately not represented as TURN. Consequences: -- MVP has **two active containers**: `db` (`postgres:16-alpine`) + `opencade-server` on `8080`; `opencade-relay` is present as a commented placeholder behind the same `/ws` fallback (peers prefer direct UDP) until M6. -- No Redis, no separate signaling service, no message queue in MVP. If load demands it, the WS relay and presence can be extracted _behind the same protocol_ — envelope versioning guarantees compatibility. -- `services/relay` is a real crate/binary from day one so `docker-compose.yml` and CI can wire it; its implementation is `TODO(relay): WS echo fallback` until M5/M6 (currently commented in compose). +- MVP has **three active containers**: `db` (`postgres:16-alpine`), `opencade-server` on `8080`, + and `opencade-relay` on `8081`; peers still prefer direct UDP. +- No Redis, no separate signaling service, and no message queue in MVP. Presence remains in the + control plane; bounded readiness frames alone can use the relay fallback. +- The relay accepts only short-lived room-member tickets issued by the control plane, limits rooms + to two users and bounded frames, and does not relay RetroArch's native netplay socket. --- @@ -440,14 +441,18 @@ Heartbeat: client pings every 20s (`{type:"ping"}`), server replies `pong`; miss ``` WAITING ──(host creates)──► WAITING -WAITING ──(peer joins)─────► READY -READY ──(host starts)────► PLAYING -READY ──(peer leaves)────► WAITING -PLAYING ──(finish)─────────► FINISHED +WAITING|CHALLENGING ──(challenge accepted)──► CONNECTING +CONNECTING ──(both authorized native processes launch)──► PLAYING +PLAYING ──(both native processes exit)──────────────────► FINISHED +CONNECTING ──(a launched process exits before peer)─────► CANCELLED * ──(host cancels / timeout / all leave)──► CANCELLED ``` -States stored in `rooms.state` (`WAITING | READY | PLAYING | FINISHED | CANCELLED`). Transitions are server-authoritative; clients optimistically render but must accept `room.state` pushes. +States stored in `rooms.state` (`WAITING | READY | CHALLENGING | CONNECTING | PLAYING | FINISHED | +CANCELLED`). Alpha rooms have exactly two members. Transitions are server-authoritative and backed by +per-participant native launch/exit records; a successful UDP readiness probe never advances room +state. Native launch descriptors are derived from short-lived, hashed, one-use server grants. See +ADR 0004. Challenge flow: `challenge.create` → server creates `challenge` row (ephemeral, in `rooms` or separate `challenges` if needed) → pushes `challenge.create` to target → target `challenge.accept/decline` → on accept, server creates/moves to `room` and emits `room.state: READY`. @@ -641,9 +646,21 @@ All three types carry `envelope.version = 1` and are validated: `roomId` must ex A single Binding server cannot honestly distinguish cone from symmetric behavior. Diagnostics and reports therefore use `unknown | open | mapped | blocked`: `open` means the reflexive address equals the advertised host address, while `mapped` only proves translation occurred. RFC 5780 behavior -discovery and a relay fallback remain deferred until physical campaign evidence justifies them. +discovery remains deferred. Authenticated WebSocket readiness-probe relay fallback is implemented, +but it is not considered physically proven until the campaign in `docs/alpha/LAN_TEST.md` passes. + +### 10.3 Authenticated readiness relay + +An active room member requests `POST /api/v1/rooms/:id/relay-ticket`. The server issues a +HMAC-SHA256 capability valid for two minutes. `opencade-relay` verifies the signature and expiry, +fixes the connection to the signed room/user, permits at most two distinct users, uses bounded +64-message queues, and rejects frames above 64 KiB. Text frames must be valid versioned envelopes +whose payload room matches the signed room; bounded binary probe frames remain opaque. + +This relay carries OpenCade readiness frames. It is not TURN and does not relay RetroArch's native +TCP netplay connection. -### 10.3 Latency — RTT / loss / jitter +### 10.4 Latency — RTT / loss / jitter `packages/networking/src/latency.ts`: @@ -655,47 +672,14 @@ Exposed via `diagnose_network` Tauri command and rendered in `/servers` and `/di --- -## 11. Emulator SDK — packages/emulator-sdk + adapters/fbneo +## 11. Emulator SDK — packages/emulator-sdk + adapters/fbneo + adapters/retroarch ### 11.1 Trait — `EmulatorAdapter` -```rust -// packages/emulator-sdk/src/adapter.rs -#[async_trait] -pub trait EmulatorAdapter: Send + Sync { - fn id(&self) -> &str; // "fbneo" - fn display_name(&self) -> &str; // "FBNeo" - async fn detect(&self) -> Result; // find binary on disk - async fn validate(&self, game: &GameDefinition) -> ValidationReport; - async fn get_version(&self) -> Result; // e.g. "0.2.97.44" - async fn launch(&self, ctx: LaunchContext) -> Result; - async fn stop(&self, handle: LaunchedProcess) -> Result<()>; - async fn configure(&self, cfg: EmulatorConfig) -> Result<()>; - fn get_supported_games(&self) -> &[String]; // from game-definitions -} - -pub struct DetectedEmulator { pub path: PathBuf, pub version: Option } -pub struct ValidationReport { pub ok: bool, pub missing: Vec } -pub struct LaunchContext { pub game: GameDefinition, pub rom_path: PathBuf, pub args: Vec } -pub struct LaunchedProcess { pub pid: u32, pub handle: Child } -``` - -```ts -// packages/emulator-sdk/src/adapter.ts — TS mirror for the UI layer -export interface EmulatorAdapter { - id: string; - displayName: string; - detect(): Promise; - validate(game: GameDefinition): Promise; - getVersion(): Promise; - launch(ctx: LaunchContext): Promise; - stop(handle: LaunchedProcess): Promise; - configure(cfg: EmulatorConfig): Promise; - getSupportedGames(): string[]; -} -``` - -`detect()` scans well-known locations (`emulator/fbneo/fcadefbneo.exe` on Windows, `~/.opencade/emulators/fbneo/` on all platforms, plus user-configured path in Settings). `getVersion()` parses the binary's version string or a sidecar `version.txt`. +The synchronous adapter trait owns detection, validation, safe local launch, optional match +preparation, native-process match launch, and shutdown. `AdapterCapabilities` makes the netplay data +plane explicit: `OpenCadeFrames`, `NativeProcess`, or `BlockedNoPublicInterface`. A successful local +launch can therefore never be mistaken for netplay support. ### 11.2 Safe launch @@ -724,6 +708,14 @@ Implements `EmulatorAdapter` for `emulator/fbneo/fcadefbneo.exe` (FBNeo 0.2.97.4 Future adapters (`flycast`, `snes9x`) implement the same trait; no SDK change required. +### 11.4 adapters/retroarch — experimental Proof of Play + +The RetroArch adapter requires a user-supplied executable, FBNeo Libretro core, and ROM below one +configured root. It constructs documented host/guest arguments without a shell and computes SHA-256 +fingerprints for the executable, core, and content. See ADR 0003. CI never redistributes emulator +software, and the adapter remains experimental until `docs/alpha/RETROARCH_TEST.md` passes on two +physical Windows machines. + --- ## 12. Game Definitions — packages/game-definitions @@ -832,7 +824,10 @@ Placeholder in MVP, real wiring without re-architecture. ### 16.1 docker-compose.yml (root) -Canonical compose is `docker-compose.yml` at repo root (mirrored under `docker/` if present). Active services: `db` (`postgres:16-alpine`) + `opencade-server` on `8080`. `opencade-relay` is commented as placeholder until `services/relay` implements the TURN relay (M6). +Canonical compose is `docker-compose.yml` at repo root. Active services are PostgreSQL, +`opencade-server` on `8080`, and the authenticated WebSocket readiness relay on `8081`. The server +and relay receive the same independently generated `RELAY_AUTH_SECRET`; it must differ from the +session secret. ```yaml # docker-compose.yml — active @@ -866,17 +861,18 @@ services: healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"] - # opencade-relay: TURN/relay placeholder for future M6. - # Uncomment when services/relay implements the relay binary. - # relay: - # image: opencade-relay:local - # ports: ["3478:3478", "3478:3478/udp"] + relay: + image: opencade-relay:local + environment: + RELAY_AUTH_SECRET: ${RELAY_AUTH_SECRET} + ports: ["8081:8081"] volumes: pgdata: ``` -Archival example with explicit relay (when enabled) listens on `3478`/`3478/udp` and proxies `SERVER_URL: http://opencade-server:8080`; until then in-process WS relay in `apps/server` is the fallback (`services/relay` placeholder, promoted to TURN later). +The relay is a WebSocket service, not a STUN or TURN listener. STUN remains an independently +configured RFC 8489 service. ### 16.2 Dockerfiles @@ -889,16 +885,16 @@ No `docker-compose.override.yml` in repo; devs create it locally if needed. ## 17. Phases M0–M7 — Exit Criteria -| Phase | Name | Exit Criteria | -| ------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **M0** | Bootstrap | Monorepo builds (`cargo check`, `pnpm build`), `docker compose up` brings `postgres` + `opencade-server` (empty), `GET /health` returns `ok`, `research/` exists and is gitignored from shipping artifacts. | -| **M1** | Protocol & DB | `packages/protocol` envelope round-trips Rust↔TS, migrations create all tables in §9, seed from `game-definitions` inserts ≥1 game, `cargo test` + `pnpm test` pass. | -| **M2** | Auth & REST | `POST /auth/register`, `/login`, `/logout`, `GET /auth/me` work with Argon2id + opaque sessions, `GET /games`, `/servers`, `/lobbies` return seeded data, auth middleware rejects unauthenticated calls with `401`, rate limiting enforced. | -| **M3** | Client shell | Tauri app launches, React Router renders all routes in §6.2 with mocked data, `tauri.conf.json` has no `shell` permission, `diagnose_*` commands return stub reports, `pnpm tauri dev` works on Windows. | -| **M4** | Emulator SDK + FBNeo | `EmulatorAdapter` trait implemented for `fbneo`, `detect` finds `fcadefbneo.exe`, `validate` reports missing `neogeo.zip` correctly, `launch` spawns with no shell and per-arg escaping, `game-definitions` TOML `schema_version=1` validates, local scan marks games present/missing, safe-launch tests prove no injection. | -| **M5** | Networking & Relay | WS `/ws` envelope works end-to-end, `signaling.offer/answer/candidate` relay through server, room state machine `WAITING→READY→PLAYING→FINISHED/CANCELLED` holds under concurrent joins, NAT fallback order verified (direct UDP → hole-punch → STUN → WS relay), `diagnose_network` reports `rtt/loss/jitter`, `services/relay` placeholder wired in Compose, `Network Test` UI passes. | -| **M6** | Lobbies, Presence, Chat | Presence `online/away/in-game` broadcasts, `chat.message` fans out to room/lobby, `challenge.create/accept/decline/cancel` flow creates a `READY` room on accept, `reports` + `bans` enforced (banned user gets `403`), end-to-end challenge→room→signaling demo with two clients. | -| **M7** | Hardening & Ship | `docker compose up --build` from clean clone, `tracing` JSON logs + `/metrics` + `/health`, `tauri-plugin-log` rotation, no secret in repo, `research/` excluded from `cargo package`/`tauri build`, load test: 100 concurrent WS, 20 rooms, p95 REST < 100 ms, docs complete, `CHANGELOG.md` cut. | +| Phase | Name | Exit Criteria | +| ------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **M0** | Bootstrap | Monorepo builds (`cargo check`, `pnpm build`), `docker compose up` brings `postgres` + `opencade-server` (empty), `GET /health` returns `ok`, `research/` exists and is gitignored from shipping artifacts. | +| **M1** | Protocol & DB | `packages/protocol` envelope round-trips Rust↔TS, migrations create all tables in §9, seed from `game-definitions` inserts ≥1 game, `cargo test` + `pnpm test` pass. | +| **M2** | Auth & REST | `POST /auth/register`, `/login`, `/logout`, `GET /auth/me` work with Argon2id + opaque sessions, `GET /games`, `/servers`, `/lobbies` return seeded data, auth middleware rejects unauthenticated calls with `401`, rate limiting enforced. | +| **M3** | Client shell | Tauri app launches, React Router renders all routes in §6.2 with mocked data, `tauri.conf.json` has no `shell` permission, `diagnose_*` commands return stub reports, `pnpm tauri dev` works on Windows. | +| **M4** | Emulator SDK + FBNeo | `EmulatorAdapter` trait implemented for `fbneo`, `detect` finds `fcadefbneo.exe`, `validate` reports missing `neogeo.zip` correctly, `launch` spawns with no shell and per-arg escaping, `game-definitions` TOML `schema_version=1` validates, local scan marks games present/missing, safe-launch tests prove no injection. | +| **M5** | Networking & Relay | WS `/ws` envelope works end-to-end, room membership scopes signaling, the room state machine holds under concurrent joins, direct UDP/STUN/hole-punching and signed-ticket relay fallback pass automated tests, `diagnose_network` reports `rtt/loss/jitter`, and physical results remain evidence-gated. | +| **M6** | Lobbies, Presence, Chat | Presence `online/away/in-game` broadcasts, `chat.message` fans out to room/lobby, `challenge.create/accept/decline/cancel` flow creates a `READY` room on accept, `reports` + `bans` enforced (banned user gets `403`), end-to-end challenge→room→signaling demo with two clients. | +| **M7** | Hardening & Ship | `docker compose up --build` from clean clone, `tracing` JSON logs + `/metrics` + `/health`, `tauri-plugin-log` rotation, no secret in repo, `research/` excluded from `cargo package`/`tauri build`, load test: 100 concurrent WS, 20 rooms, p95 REST < 100 ms, docs complete, `CHANGELOG.md` cut. | No phase is considered done until its exit criteria are demonstrated on a clean machine (`git clone` → `docker compose up` → `pnpm tauri dev`). diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c2f6452..2e30575 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -7,6 +7,9 @@ Updated 2026-08-24. - Composed Axum runtime with automatic SQLx migrations, liveness/readiness, strict production configuration, scoped CORS, and redacted errors. - Argon2 registration/login, hashed opaque sessions, revocation, authenticated REST and WebSocket. +- Per-identity and bounded global authentication throttles, equal-cost unknown-user password work, + operating-system credential storage for desktop sessions, and a production CSP that requires TLS + for non-loopback control planes. - Durable addressed challenges with ownership rules; transactional room and match lifecycle. - Bounded/rate-limited WebSocket signaling restricted to authenticated room members with correlated acknowledgements and errors. @@ -24,9 +27,25 @@ Updated 2026-08-24. - RFC 8489 reflexive-address discovery and bounded authenticated UDP hole punching on the same reserved socket, with host/reflexive candidate evidence in redacted reports. - A fail-closed campaign summarizer that derives the 8-of-10 alpha gate and compatibility matrix - from paired reports. + from paired reports, separating direct-UDP and authenticated-relay outcomes. +- Privacy-minimized failure evidence for endpoint, relay, transcript, room-transition, and native + launch failures, so abandoned rooms count against the campaign instead of disappearing. - A standalone `opencade-relay` service with health/readiness routes and bounded room forwarding, plus server-provided STUN hints and 30-sample latency metrics. +- Short-lived HMAC relay tickets issued only to active room members, immutable room scoping, + two-peer limits, bounded queues/frames, and automatic desktop readiness-probe fallback. +- A process-boundary RetroArch + FBNeo-core adapter with explicit `NativeProcess` capability, + safe host/guest argument construction, and SHA-256 executable/core/content fingerprints. +- Server-derived one-use native launch grants, exactly-two-player rooms, a deterministic client + match coordinator, supervised/reaped child processes, and launch/exit-backed room transitions. +- A truthful route gate: same-LAN host candidates may launch the RetroArch TCP alpha; UDP reflexive + and relay results are readiness-only until a native transport bridge exists. +- Strict playable-report verification that requires matching, well-formed native compatibility + fingerprints (`opencade-match-verify --require-compatibility`). +- Runtime-configurable desktop server/STUN endpoints and a CI-built Windows desktop alpha executable + with SHA-256 checksums alongside the probe/verifier/summarizer tools. +- A flat Windows alpha kit with a CI-exercised PowerShell packager/verifier, machine doctor, + runtime configuration launcher, and dedicated report directory. - Desktop network diagnostics backed by real RFC 8489 Binding when a STUN endpoint is configured, with typed RTT, loss, jitter, relay-reachability, and STUN-reachability fields. - PostgreSQL, WebSocket, lifecycle, safe-launch, mock-match, UDP, NAT, relay, two-process, @@ -35,11 +54,12 @@ Updated 2026-08-24. ## Deliberately not claimed -- FBNeo netplay. The adapter reports `BlockedNoPublicInterface`; ADR 0002 records the public-source - feasibility result and exit criteria. -- Cone/symmetric NAT behavior classification and proven relay fallback. One RFC 8489 Binding response is +- Standalone FBNeo netplay. That adapter reports `BlockedNoPublicInterface`; ADR 0002 records the + public-source feasibility result. The separate RetroArch native-process path has no physical + two-machine evidence yet and remains experimental under ADR 0003. +- Cone/symmetric NAT behavior classification and physically proven relay fallback. One RFC 8489 Binding response is deliberately reported only as `open`, `mapped`, or `unknown`. - TURN allocation and production STUN/TURN deployment. -- Production packaging/signing, friends/chat/rankings/replays, or a public MVP release. +- Production installer packaging/signing, friends/chat/rankings/replays, or a public MVP release. - Ten two-machine LAN matches and a 20-person community alpha; these require external testers and real Windows hosts. Use `docs/alpha/LAN_TEST.md` to collect that evidence. diff --git a/docs/NEXT_IMPLEMENTATIONS.md b/docs/NEXT_IMPLEMENTATIONS.md index 9cb9b4d..e86b60a 100644 --- a/docs/NEXT_IMPLEMENTATIONS.md +++ b/docs/NEXT_IMPLEMENTATIONS.md @@ -13,6 +13,29 @@ > the gate; cone/symmetric classification and relay fallback remain deferred. The sequencing below > is retained as the original pre-evidence plan. +> **Proof-of-Play update (2026-08-24):** ADR 0003, the user-supplied RetroArch/FBNeo-core +> native-process adapter, compatibility fingerprints, signed relay tickets, bounded relay fallback, +> runtime-configurable Windows alpha executable, and automated tests are implemented. The next gate +> is execution, not another subsystem: run the physical two-Windows campaign in +> `docs/alpha/RETROARCH_TEST.md` and `docs/alpha/LAN_TEST.md` without fabricating results. + +> **Campaign-kit update (2026-08-24):** CI now produces a flat, checksummed Windows alpha kit. Its +> shared PowerShell entrypoint packages and verifies the artifact in CI, then verifies API +> health/readiness, the user-supplied RetroArch layout, optional STUN syntax, and runtime launch +> configuration on tester machines. The remaining gate is still physical execution and paired +> report collection. + +> **Evidence-integrity update (2026-08-24):** abandoned rooms now export a distinct redacted +> failure-evidence record with an enum stage and validated stable code. Campaign aggregation counts +> unique successful or failed room IDs, separates direct/relay rows, and rejects conflicting +> success/failure evidence. This removes survivorship bias from the 8-of-10 gate. + +> **Native-lifecycle hardening (2026-08-24):** opportunities scoring above 20 are implemented in +> ADR 0004: server-derived one-use launch authorization, two-participant launch/exit state, +> supervised processes, a deterministic client coordinator, strict compatibility evidence, +> authentication throttling/timing equalization, secure desktop token storage, and a narrowed CSP. +> Relay and reflexive UDP probes are deliberately readiness-only for the RetroArch TCP alpha. + ## Recommendation: build the executable Proof of Match The next implementation should be one narrow, deterministic vertical slice: diff --git a/docs/adr/0003-retroarch-native-process-netplay.md b/docs/adr/0003-retroarch-native-process-netplay.md new file mode 100644 index 0000000..8884556 --- /dev/null +++ b/docs/adr/0003-retroarch-native-process-netplay.md @@ -0,0 +1,72 @@ +# ADR 0003: Prove play through a user-supplied RetroArch process + +- Status: accepted, alpha evidence pending +- Date: 2026-08-24 + +## Context + +ADR 0002 correctly keeps standalone FBNeo netplay blocked because its public command-line surface +does not expose a stable endpoint or session contract. OpenCade nevertheless needs one legally and +technically reviewable path from an authenticated room to a real two-player emulator session. + +RetroArch publicly documents loading a Libretro core and content as separate command-line arguments, +host/client netplay modes, a netplay port, and compatible content requirements. This is a process +interface: OpenCade does not link RetroArch or an emulator core and does not redistribute either. + +Sources reviewed for this decision: + +- https://github.com/libretro/docs/blob/master/docs/guides/cli-intro.md +- https://github.com/libretro/docs/blob/master/docs/guides/netplay-faq.md +- https://github.com/libretro/RetroArch/blob/master/retroarch.cfg +- https://github.com/finalburnneo/FBNeo/issues/2319 + +## Decision + +Add `NetplayMode::NativeProcess` beside `OpenCadeFrames` and +`BlockedNoPublicInterface`. The first native-process adapter targets a separately installed +RetroArch executable plus a separately installed FBNeo Libretro core. + +The adapter: + +1. detects the executable and core below one configured root; +2. canonicalizes the executable, core, and ROM below their allowlisted roots; +3. hashes all three inputs with SHA-256 and records the declared RetroArch version; +4. starts the host with documented host/port arguments; +5. starts the guest with documented connect/port arguments; +6. passes each argument directly to `Command`, never through a shell; and +7. keeps process ownership in the existing bounded desktop process registry. + +For the LAN alpha, RetroArch uses TCP port `55435`. The guest derives the host IP from the already +authenticated OpenCade candidate exchange. OpenCade's deterministic UDP/relay probe remains a +separate readiness test and evidence source; it is not presented as RetroArch's internal data plane. + +## Distribution and license boundary + +- Users provide RetroArch, the FBNeo core, BIOS files, and ROMs. +- CI artifacts contain only original Apache-2.0 OpenCade code. +- OpenCade does not dynamically or statically link an emulator. +- Paths, binaries, hashes, ROM names, and content are not uploaded by the alpha report. +- This ADR records an engineering boundary, not a legal opinion. Any future redistribution requires + an independent license review. + +## Evidence gates + +The adapter is implemented, but real netplay remains unproven until two Windows machines demonstrate: + +1. identical executable/core/content fingerprints; +2. one host and one guest launched from the same OpenCade room; +3. a successful RetroArch netplay connection and player-two input; +4. clean process shutdown; and +5. paired, privacy-minimized OpenCade transport reports. + +If no stable automated connection-success signal can be obtained from the documented process +surface, retain this adapter as experimental and do not advertise one-click emulator netplay. + +## Consequences + +- Standalone FBNeo remains local-play only. +- OpenCade becomes capable of orchestrating both its own deterministic frame transport and an + emulator-owned native netplay transport without conflating them. +- Exact compatibility fingerprints become a prerequisite for playable alpha sessions. +- Direct UDP failure may fall back to OpenCade's authenticated WebSocket relay for the readiness + probe; that does not transparently relay RetroArch's native TCP session. diff --git a/docs/adr/0004-authoritative-native-match-lifecycle.md b/docs/adr/0004-authoritative-native-match-lifecycle.md new file mode 100644 index 0000000..ab1825d --- /dev/null +++ b/docs/adr/0004-authoritative-native-match-lifecycle.md @@ -0,0 +1,33 @@ +# ADR 0004: Authorize and observe the native match lifecycle + +- Status: accepted +- Date: 2026-08-24 + +## Context + +A successful readiness probe is not proof that an emulator started or gameplay occurred. OpenCade's +probe and relay carry OpenCade UDP frames; RetroArch owns a separate TCP netplay process. Advancing a +room from `CONNECTING` to `PLAYING` or `FINISHED` from probe results therefore produced false state. + +## Decision + +- Alpha rooms contain exactly two participants. +- The server derives room, game, users, and role, then issues a hashed, 90-second, one-use launch + grant bound to numeric endpoints and input delay. +- The Tauri core consumes the grant directly and constructs the native descriptor from the server + response. The webview cannot provide room identity, peer identity, role, or game to the launcher. +- Each participant confirms launch only after process spawn. The room becomes `PLAYING` only after + both launches are recorded. +- Tauri owns, drains, reaps, and reports the child process. A room becomes `FINISHED` only after both + recorded native processes exit; an exit while waiting for the peer cancels the room. +- Only a verified same-LAN host candidate can launch RetroArch in this alpha. UDP reflexive and + authenticated-relay probes remain readiness evidence and are explicitly non-playable because + they do not carry RetroArch's TCP netplay traffic. +- Playable evidence verification can require matching, syntactically valid executable, core, and + content fingerprints. + +## Consequences + +Room state is now evidence-backed and retry-safe at the process boundary. Relay and NAT traversal +remain honest capability gaps instead of being presented as gameplay. A future native transport +bridge must add an explicit route capability before those paths can enable launch. diff --git a/docs/alpha/LAN_TEST.md b/docs/alpha/LAN_TEST.md index 3fc56bd..c175986 100644 --- a/docs/alpha/LAN_TEST.md +++ b/docs/alpha/LAN_TEST.md @@ -9,11 +9,14 @@ direct UDP frame transport; it does not claim FBNeo netplay support. `docker compose up --build -d`. For different-NAT tests, deploy the same server behind a publicly reachable HTTPS/WSS endpoint; do not expose a developer laptop directly. 2. Confirm `GET /health` and `GET /ready` both return HTTP 200. -3. Point both clients at the LAN server or deployed public control-plane URL with `VITE_API_URL`. - For LAN, allow TCP 8080 through the host firewall. -4. For the different-NAT phase, set `VITE_STUN_SERVER=:3478` on both clients. The server - must implement RFC 8489 Binding. DNS names are deliberately not resolved by the alpha client. -5. Run `pnpm -C apps/client tauri dev` on both Windows machines. +3. Point both clients at the LAN server or deployed public control-plane URL with + `OPENCADE_API_URL`. For LAN, allow TCP 8080 and 8081 through the host firewall. +4. For the different-NAT phase, set `OPENCADE_STUN_SERVER=:3478` on both clients. The + STUN service must implement RFC 8489 Binding. DNS names are deliberately not resolved by the + alpha client. +5. Download the flat `opencade-windows-alpha` artifact and run `OpenCade-Alpha.ps1 -Mode Doctor` + on both Windows machines before using `-Mode Launch`. Repository contributors may instead use + `pnpm -C apps/client tauri dev`. 6. Use fixture-free local ROM scanning only; never attach ROMs or emulator binaries to reports. ## Scenario @@ -30,7 +33,11 @@ direct UDP frame transport; it does not claim FBNeo netplay support. transcript checksum. The host then transitions the room to `playing` and `finished`. 7. If either side reports a firewall or timeout error, allow the advertised UDP port through the firewall and select `Retry LAN probe` on both clients. -8. Export the redacted report from each client. Copy both reports to one machine and verify them: +8. If the attempt is abandoned, select **Export failure evidence** before leaving the match screen. + This records only a stable stage/code—never the displayed diagnostic message, endpoints, or + paths. One or two failure files for the room count as one failed campaign attempt. +9. For a completed attempt, export the redacted report from each client. Copy both reports to one + machine and verify them: ```bash opencade-match-verify host-report.json guest-report.json @@ -52,8 +59,9 @@ cargo run -p opencade-networking --bin opencade-match-probe -- \ The other host swaps local/peer addresses and users and uses `--role guest` with the same room, game, session key, frame count, and timeout. The command prints the same canonical, redacted JSON -format as the desktop client. Download both Windows tools from the `opencade-lan-tools-windows` -artifact on a successful `main` CI run, or build them with: +format as the desktop client. Download the client and Windows tools from the flat, +checksum-verified `opencade-windows-alpha` artifact on a successful `main` CI run, or build them +with: ```bash cargo build -p opencade-networking --bins --release --locked @@ -67,15 +75,17 @@ cargo build -p opencade-networking --bins --release --locked - `opencade-match-verify` accepts the host/guest report pair: same room, game, checksum, finished state, opposite roles, direct UDP, and exactly 60 received frames. - The match row has `started_at` and `ended_at`. -- Reports contain no session token, password, full ROM path, or emulator binary. +- Successful and failure reports contain no session token, password, endpoint, diagnostic message, + full ROM path, or emulator binary. -Record each attempt in `MATCH_REPORT_TEMPLATE.md`. Do not schedule relay work until ten LAN attempts -have at least an 80% connection-and-completion rate or show one concentrated, fixable failure. +Record each attempt in `MATCH_REPORT_TEMPLATE.md`. Run both direct-UDP and authenticated-relay +attempts: direct UDP measures the preferred path, while relay verifies the production fallback. ## Campaign gate -Run five same-LAN and five different-NAT attempts, storing exactly two JSON reports per room in one -directory. Then derive the gate and compatibility matrix: +Run five same-LAN and five different-NAT attempts. Store two successful JSON reports per completed +room, or at least one failure-evidence JSON file per abandoned room, in one directory. Then derive +the gate and compatibility matrix: ```bash opencade-alpha-summary ./campaign-reports @@ -85,3 +95,9 @@ Exit 0 means at least 8 of 10 rooms passed. Exit 1 means the evidence was readab not pass; exit 2 means the input was invalid. A single Binding response only proves `open` (the mapped address equals the advertised host address) or `mapped`; it does not claim cone/symmetric NAT classification. That requires RFC 5780 behavior discovery and remains deferred. + +The summarizer counts unique room IDs rather than files, separates direct and relay rows, validates +stable failure codes, and rejects a room containing both success and failure evidence. + +After the readiness campaign passes, use `RETROARCH_TEST.md` for the separate Proof-of-Play gate. +Readiness-probe success does not by itself prove emulator netplay. diff --git a/docs/alpha/MATCH_REPORT_TEMPLATE.md b/docs/alpha/MATCH_REPORT_TEMPLATE.md index 61cd5ba..ef7acfe 100644 --- a/docs/alpha/MATCH_REPORT_TEMPLATE.md +++ b/docs/alpha/MATCH_REPORT_TEMPLATE.md @@ -6,7 +6,8 @@ - Windows versions: - Game id: - Emulator adapter/version: -- Transport: direct UDP +- Executable/core/content fingerprints match: yes/no +- Transport: direct UDP/authenticated relay - Same subnet: yes/no - Setup time (minutes): - Challenge-to-connecting time (ms): @@ -15,4 +16,5 @@ - Packet loss observed: - Completed: yes/no - Disconnect/failure code: +- Failure evidence filename (when abandoned): - Reproduction notes (no credentials, ROMs, binaries, or full local paths): diff --git a/docs/alpha/RETROARCH_TEST.md b/docs/alpha/RETROARCH_TEST.md new file mode 100644 index 0000000..19a7edc --- /dev/null +++ b/docs/alpha/RETROARCH_TEST.md @@ -0,0 +1,83 @@ +# RetroArch Proof-of-Play alpha + +This procedure tests the experimental native-process adapter from ADR 0003. It requires two real +Windows machines and user-supplied emulator files. Never commit, upload, or attach emulator binaries, +cores, BIOS files, or ROMs to OpenCade reports. + +## Get the alpha artifact + +Download `opencade-windows-alpha` from a successful `main` CI run. Verify every file against +`SHA256SUMS.txt` before running it. The artifact is a flat directory; run the same verifier used by +CI from inside it: + +```powershell +powershell -ExecutionPolicy Bypass -File .\OpenCade-Alpha.ps1 -Mode Verify +``` + +The artifact is an unsigned alpha executable, not a production installer. Windows may display a +reputation warning. Production signing remains a separate M7 gate. + +## Prepare RetroArch on both machines + +Create the same directory layout on each machine: + +```text +C:\OpenCadeAlpha\retroarch\ +├── retroarch.exe +├── VERSION.txt # exact RetroArch version, one line +├── cores\ +│ └── fbneo_libretro.dll +└── ROMs\ + ├── neogeo.zip # when required by the selected game + └── sfiii3.zip # user-supplied content +``` + +Use the same RetroArch version, FBNeo core, and exact content on both machines. OpenCade computes +SHA-256 fingerprints locally and includes only those hashes—not paths or content—in an exported +report. + +## Start the clients + +On each machine, open PowerShell in the alpha artifact directory. The alpha doctor verifies the +artifact, control-plane health/readiness, optional STUN syntax, and required RetroArch layout before +starting the client: + +```powershell +powershell -ExecutionPolicy Bypass -File .\OpenCade-Alpha.ps1 -Mode Launch ` + -ApiUrl "https://alpha.example.com" ` + -RetroArchRoot "C:\OpenCadeAlpha\retroarch" ` + -StunServer "203.0.113.10:3478" +``` + +Remote control-plane URLs must use HTTPS. Loopback development may use +`http://127.0.0.1:8080`. Allow the server TCP port, the OpenCade probe UDP ports, and RetroArch TCP +port `55435` through the appropriate firewalls. Do not expose a developer laptop directly to the +public internet. + +## Scenario + +1. Register two different OpenCade users and select the same game. +2. Challenge and accept until both clients reach the match screen. +3. Wait for `Direct UDP verified` with a same-LAN host candidate and matching 60-frame transcript + checksums. Relay and reflexive routes are readiness-only and keep native launch disabled. +4. Select **Launch playable alpha** on the host first, then on the guest. +5. Confirm both clients display the same executable, core, and content hash prefixes. +6. Confirm RetroArch establishes netplay and player two can provide gameplay input. +7. End the session cleanly. Export one report from each OpenCade client after the native process has + launched so the compatibility fingerprints are included. +8. Run `opencade-match-verify --require-compatibility host.json guest.json`. Missing or mismatched + adapter fingerprints, transport, transcript, room, game, or roles must fail closed. +9. If RetroArch launch fails, export failure evidence before leaving the match screen. The report + records `native_launch_failed`, but never the local launch error or path. + +## Pass criteria + +- Both OpenCade readiness reports verify as a pair. +- Both reports contain identical native-process compatibility fingerprints. +- The host and guest processes were launched using the documented process boundary without a shell. +- RetroArch reports a connected netplay session and player-two input is observed. +- No report contains a local path, endpoint, user identifier, session token, ROM name, or binary. + +Record manual gameplay observations in `MATCH_REPORT_TEMPLATE.md`. Do not mark RetroArch netplay as +proven until at least one same-LAN Proof-of-Play passes; do not mark internet play as proven until the +five different-NAT campaign attempts meet the aggregate gate. diff --git a/docs/alpha/reports/README.md b/docs/alpha/reports/README.md index 8ac858d..2544f66 100644 --- a/docs/alpha/reports/README.md +++ b/docs/alpha/reports/README.md @@ -4,9 +4,11 @@ This directory is intentionally empty in the repository. Each physical LAN attem of canonical schema-v1 redacted JSON reports via: - Desktop: `Export report` after the room reaches `finished`. +- Desktop: `Export failure evidence` when an attempt is abandoned. - CLI probe: `opencade-match-probe --local --peer --room --game sfiii3 --local-user host --peer-user guest --role host --session-key --frames 60 --timeout-ms 5000`. -Both producers omit identities, endpoints, nonces, session material, credentials, and local paths. +All producers omit identities, endpoints, nonces, session material, credentials, diagnostic +messages, and local paths. Local 10/10 proof (single box, loopback) is automated: @@ -17,16 +19,17 @@ for i in 1..10; do cargo test -p opencade-networking --test two_process_probe -- Result 10/10 on 2026-08-24 (see CI). Physical LAN requires 2× Windows 10/11 on the same subnet, `docker compose up --build -d`, -`VITE_API_URL=http://:8080`, firewall TCP 8080 + UDP probe ports. Follow -`docs/alpha/LAN_TEST.md`, save each attempt as `attempt-01-host.json` and -`attempt-01-guest.json`, then summarize the directory: +`OPENCADE_API_URL=http://:8080`, a client-reachable `RELAY_URL`, firewall TCP +8080/8081 + UDP probe ports. Run the alpha-kit doctor, follow `docs/alpha/LAN_TEST.md`, save each +successful attempt as `attempt-01-host.json` and `attempt-01-guest.json`, or an abandoned attempt as +`attempt-01-failure.json`, then summarize the directory: ```bash opencade-alpha-summary . ``` The summary pairs reports by room ID, verifies each pair, and emits a compatibility matrix by game, -platform, observed mapping, and selected candidate. Pass is at least 8 verified attempts out of 10. -Do not fabricate reports—they must come from real +platform, observed mapping, selected transport, and optional RetroArch/core/content fingerprints. +Pass is at least 8 verified attempts out of 10. Do not fabricate reports—they must come from real two-machine runs. Until then, this gate is `HALT: physical LAN not available in this single-box environment—local 10/10 + evidence tooling ready`. diff --git a/packages/emulator-sdk/src/lib.rs b/packages/emulator-sdk/src/lib.rs index 14595c5..399897e 100644 --- a/packages/emulator-sdk/src/lib.rs +++ b/packages/emulator-sdk/src/lib.rs @@ -128,6 +128,37 @@ pub enum TransportKind { Relay, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportLease { + pub kind: TransportKind, + pub local_endpoint: SocketAddr, + pub peer_endpoint: SocketAddr, +} + +impl TransportLease { + pub fn native_process( + kind: TransportKind, + local_endpoint: SocketAddr, + peer_endpoint: SocketAddr, + ) -> Result { + if kind != TransportKind::DirectUdp { + return Err(AdapterError::MatchPreparation( + "native-process netplay requires an explicitly authorized direct route".into(), + )); + } + if local_endpoint.port() == 0 || peer_endpoint.port() == 0 { + return Err(AdapterError::MatchPreparation( + "transport lease endpoints require non-zero ports".into(), + )); + } + Ok(Self { + kind, + local_endpoint, + peer_endpoint, + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MatchDescriptor { pub room_id: String, @@ -164,23 +195,30 @@ impl MatchDescriptor { } Ok(()) } + + pub fn transport_lease(&self) -> Result { + TransportLease::native_process(self.transport, self.local_endpoint, self.peer_endpoint) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AdapterCapabilities { pub local_play: bool, - pub netplay: NetplayReadiness, + pub netplay: NetplayMode, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NetplayReadiness { - Supported, +pub enum NetplayMode { + /// OpenCade owns the deterministic input-frame data plane. + OpenCadeFrames, + /// A separately installed emulator owns netplay through a documented process interface. + NativeProcess, BlockedNoPublicInterface, } impl AdapterCapabilities { pub fn supports_netplay(self) -> bool { - self.netplay == NetplayReadiness::Supported + self.netplay != NetplayMode::BlockedNoPublicInterface } } @@ -217,6 +255,16 @@ pub trait EmulatorAdapter: Send + Sync { /// Implementations MUST NOT use shell injection; pass args directly. fn launch(&self, rom_path: &Path) -> Result; + /// Launch a netplay match. Adapters with a native process interface override this method. + fn launch_match( + &self, + rom_path: &Path, + descriptor: &MatchDescriptor, + ) -> Result { + self.prepare_match(descriptor)?; + self.launch(rom_path) + } + /// Stop a running emulator child. fn stop(&self, child: &mut Child) -> Result<(), AdapterError>; } @@ -244,7 +292,7 @@ impl EmulatorAdapter for MockAdapter { fn capabilities(&self) -> AdapterCapabilities { AdapterCapabilities { local_play: false, - netplay: NetplayReadiness::Supported, + netplay: NetplayMode::OpenCadeFrames, } } @@ -319,6 +367,13 @@ mod tests { assert!(value.validate().is_err()); } + #[test] + fn native_transport_lease_rejects_probe_only_relay() { + let mut descriptor = descriptor(); + descriptor.transport = TransportKind::Relay; + assert!(descriptor.transport_lease().is_err()); + } + #[derive(Default)] struct RecordingLauncher; diff --git a/packages/networking/Cargo.toml b/packages/networking/Cargo.toml index 1e7d5a1..179c8c6 100644 --- a/packages/networking/Cargo.toml +++ b/packages/networking/Cargo.toml @@ -2,7 +2,7 @@ name = "opencade-networking" version = "0.1.0" edition = "2024" -description = "OpenCade networking — signaling, NAT traversal, relay (placeholder for M1)" +description = "OpenCade networking — deterministic probes, NAT traversal, and relay transport" license = "Apache-2.0" repository = "https://github.com/opencade/opencade" rust-version = "1.98" @@ -11,10 +11,14 @@ rust-version = "1.98" chrono = "0.4.45" opencade-emulator-sdk = { path = "../emulator-sdk" } opencade-protocol = { path = "../protocol" } +opencade-shared = { path = "../shared" } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" thiserror = "2.0.20" tokio = { version = "1.53.1", features = ["macros", "net", "rt", "sync", "time"] } uuid = { version = "1.25.0", features = ["v4"] } +futures-util = { version = "0.3.34", default-features = false, features = ["sink", "std"] } +tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-native-roots"] } +url = "2.5.8" [dev-dependencies] diff --git a/packages/networking/src/bin/opencade-alpha-summary.rs b/packages/networking/src/bin/opencade-alpha-summary.rs index 33d5899..daacec4 100644 --- a/packages/networking/src/bin/opencade-alpha-summary.rs +++ b/packages/networking/src/bin/opencade-alpha-summary.rs @@ -1,4 +1,6 @@ -use opencade_networking::{read_match_report, summarize_match_reports}; +use opencade_networking::{ + AlphaCampaignEvidence, read_campaign_evidence, summarize_campaign_evidence, +}; use std::path::{Path, PathBuf}; use std::{env, fs, process}; @@ -32,16 +34,20 @@ fn run() -> Result { } let mut reports = Vec::with_capacity(paths.len()); + let mut failures = Vec::new(); for path in paths { let filename = path .file_name() .and_then(|value| value.to_str()) .unwrap_or(""); - let report = read_match_report(&path) + let evidence = read_campaign_evidence(&path) .map_err(|error| format!("{filename}: {} ({})", error, error.code()))?; - reports.push(report); + match evidence { + AlphaCampaignEvidence::Match(report) => reports.push(report), + AlphaCampaignEvidence::Failure(report) => failures.push(report), + } } - let summary = summarize_match_reports(&reports); + let summary = summarize_campaign_evidence(&reports, &failures); let output = serde_json::to_string_pretty(&summary) .map_err(|_| "failed to serialize campaign summary".to_string())?; println!("{output}"); diff --git a/packages/networking/src/bin/opencade-match-probe.rs b/packages/networking/src/bin/opencade-match-probe.rs index 6977eb4..b7605f0 100644 --- a/packages/networking/src/bin/opencade-match-probe.rs +++ b/packages/networking/src/bin/opencade-match-probe.rs @@ -132,6 +132,7 @@ async fn main() -> Result<(), Box> { platform: std::env::consts::OS.into(), user_agent: format!("opencade-match-probe/{}", env!("CARGO_PKG_VERSION")), }, + compatibility: None, }; println!("{}", serde_json::to_string(&report)?); Ok(()) diff --git a/packages/networking/src/bin/opencade-match-verify.rs b/packages/networking/src/bin/opencade-match-verify.rs index 3d901ca..88581f5 100644 --- a/packages/networking/src/bin/opencade-match-verify.rs +++ b/packages/networking/src/bin/opencade-match-verify.rs @@ -1,4 +1,4 @@ -use opencade_networking::{read_match_report, verify_match_reports}; +use opencade_networking::{read_match_report, verify_match_reports, verify_playable_match_reports}; use opencade_protocol::MatchReport; use serde::Serialize; use std::path::Path; @@ -36,7 +36,12 @@ fn main() { fn run() -> Result { let mut args = env::args().skip(1); - let first_path = args.next().ok_or_else(usage_failure)?; + let first_arg = args.next().ok_or_else(usage_failure)?; + let (strict, first_path) = if first_arg == "--require-compatibility" { + (true, args.next().ok_or_else(usage_failure)?) + } else { + (false, first_arg) + }; let second_path = args.next().ok_or_else(usage_failure)?; if args.next().is_some() { return Err(usage_failure()); @@ -44,8 +49,12 @@ fn run() -> Result { let first = read_report(&first_path, "first")?; let second = read_report(&second_path, "second")?; - let verification = verify_match_reports(&first, &second) - .map_err(|error| (Failure::new(error.code(), error.to_string()), 1))?; + let verification = if strict { + verify_playable_match_reports(&first, &second) + } else { + verify_match_reports(&first, &second) + } + .map_err(|error| (Failure::new(error.code(), error.to_string()), 1))?; serde_json::to_string(&verification).map_err(|_| { ( Failure::new("internal_error", "failed to serialize verification result"), @@ -63,7 +72,7 @@ fn usage_failure() -> (Failure, i32) { ( Failure::new( "usage", - "usage: opencade-match-verify FIRST_REPORT.json SECOND_REPORT.json", + "usage: opencade-match-verify [--require-compatibility] FIRST_REPORT.json SECOND_REPORT.json", ), 2, ) diff --git a/packages/networking/src/lib.rs b/packages/networking/src/lib.rs index 1ba3feb..7d049e5 100644 --- a/packages/networking/src/lib.rs +++ b/packages/networking/src/lib.rs @@ -6,6 +6,7 @@ pub mod latency; pub mod nat; mod probe; +mod relay; mod report; mod stun; mod traversal; @@ -14,11 +15,16 @@ pub use latency::LatencyMetrics; pub use nat::{ FALLBACK_ORDER, FallbackOrder, NatTraversal, NatType, next_transport, transport_for_nat, }; -pub use probe::{MAX_PROBE_FRAMES, MatchProbeConfig, MatchProbeReport, run_match_probe}; +pub use probe::{ + MAX_PROBE_FRAMES, MatchProbeConfig, MatchProbeReport, run_match_probe, run_relay_match_probe, +}; +pub use relay::RelayPeer; pub use report::{ - ALPHA_MATCH_FRAMES, AlphaCampaignFailure, AlphaCampaignSummary, CompatibilityResult, - MAX_REPORT_BYTES, MatchVerification, ReportReadError, ReportVerificationError, - read_match_report, summarize_match_reports, verify_match_reports, + ALPHA_MATCH_FRAMES, AlphaCampaignEvidence, AlphaCampaignFailure, AlphaCampaignSummary, + CompatibilityResult, MAX_REPORT_BYTES, MatchVerification, ReportReadError, + ReportVerificationError, read_campaign_evidence, read_match_report, + summarize_campaign_evidence, summarize_match_reports, verify_match_reports, + verify_playable_match_reports, }; pub use stun::{NatMapping, StunObservation, discover_reflexive_address}; pub use traversal::{HolePunchConfig, HolePunchReport, punch_hole}; @@ -85,6 +91,8 @@ pub enum TransportError { InvalidStunResponse(String), #[error("UDP hole punch timed out after {attempts} attempts")] HolePunchTimeout { attempts: u8 }, + #[error("relay transport failed: {0}")] + Relay(String), } /// Connected UDP transport for LAN proof runs. Authentication and endpoint negotiation remain in diff --git a/packages/networking/src/probe.rs b/packages/networking/src/probe.rs index f128c51..2741180 100644 --- a/packages/networking/src/probe.rs +++ b/packages/networking/src/probe.rs @@ -1,4 +1,4 @@ -use super::{InputFrame, MAX_INPUT_BYTES, TransportError, UdpPeer}; +use super::{InputFrame, MAX_INPUT_BYTES, RelayPeer, TransportError, UdpPeer}; use opencade_emulator_sdk::{MatchDescriptor, PeerRole, TransportKind}; use opencade_protocol::{MatchCandidateKind, NatMappingState}; use serde::{Deserialize, Serialize}; @@ -45,9 +45,12 @@ impl MatchProbeConfig { descriptor .validate() .map_err(|error| TransportError::InvalidConfiguration(error.to_string()))?; - if descriptor.transport != TransportKind::DirectUdp { + if !matches!( + descriptor.transport, + TransportKind::DirectUdp | TransportKind::Relay + ) { return Err(TransportError::InvalidConfiguration( - "match probe requires direct_udp transport".into(), + "match probe requires direct_udp or relay transport".into(), )); } let session_key = session_key.into(); @@ -98,6 +101,45 @@ pub struct MatchProbeReport { pub async fn run_match_probe( peer: &UdpPeer, config: &MatchProbeConfig, +) -> Result { + run_match_probe_with(peer, config).await +} + +pub async fn run_relay_match_probe( + peer: &RelayPeer, + config: &MatchProbeConfig, +) -> Result { + run_match_probe_with(peer, config).await +} + +trait ProbeTransport { + async fn send_packet(&self, packet: &ProbePacket) -> Result<(), TransportError>; + async fn receive_packet(&self) -> Result; +} + +impl ProbeTransport for UdpPeer { + async fn send_packet(&self, packet: &ProbePacket) -> Result<(), TransportError> { + self.send_packet(packet).await + } + + async fn receive_packet(&self) -> Result { + self.receive_packet().await + } +} + +impl ProbeTransport for RelayPeer { + async fn send_packet(&self, packet: &ProbePacket) -> Result<(), TransportError> { + self.send_packet(packet).await + } + + async fn receive_packet(&self) -> Result { + self.receive_packet().await + } +} + +async fn run_match_probe_with( + peer: &T, + config: &MatchProbeConfig, ) -> Result { let started = Instant::now(); let deadline = started + config.timeout; @@ -181,8 +223,8 @@ pub async fn run_match_probe( }) } -async fn send_probe_frame( - peer: &UdpPeer, +async fn send_probe_frame( + peer: &T, config: &MatchProbeConfig, frame: InputFrame, ) -> Result<(), TransportError> { @@ -224,8 +266,8 @@ fn validated_remote_frame( Ok(Some(frame)) } -async fn linger_for_peer( - peer: &UdpPeer, +async fn linger_for_peer( + peer: &T, config: &MatchProbeConfig, frames_sent: &mut u64, ) -> Result<(), TransportError> { diff --git a/packages/networking/src/relay.rs b/packages/networking/src/relay.rs new file mode 100644 index 0000000..933a25a --- /dev/null +++ b/packages/networking/src/relay.rs @@ -0,0 +1,72 @@ +use super::{TransportError, probe::ProbePacket}; +use futures_util::{SinkExt, StreamExt}; +use opencade_shared::RelayTicket; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; +use url::Url; + +pub struct RelayPeer { + stream: Mutex>>, +} + +impl RelayPeer { + pub async fn connect(relay_url: &str, ticket: &RelayTicket) -> Result { + let mut url = Url::parse(relay_url) + .map_err(|_| TransportError::Relay("relay URL is invalid".into()))?; + if !matches!(url.scheme(), "ws" | "wss") { + return Err(TransportError::Relay("relay URL must use ws or wss".into())); + } + url.query_pairs_mut() + .append_pair("room_id", &ticket.room_id) + .append_pair("user_id", &ticket.user_id) + .append_pair("expires_at", &ticket.expires_at.to_string()) + .append_pair("signature", &ticket.signature); + let (stream, _) = connect_async(url.as_str()) + .await + .map_err(|_| TransportError::Relay("relay connection failed".into()))?; + Ok(Self { + stream: Mutex::new(stream), + }) + } + + pub(super) async fn send_packet(&self, packet: &ProbePacket) -> Result<(), TransportError> { + let encoded = serde_json::to_vec(packet) + .map_err(|error| TransportError::Serialization(error.to_string()))?; + self.stream + .lock() + .await + .send(Message::Binary(encoded.into())) + .await + .map_err(|_| TransportError::Relay("relay send failed".into())) + } + + pub(super) async fn receive_packet(&self) -> Result { + let mut stream = self.stream.lock().await; + loop { + match stream.next().await { + Some(Ok(Message::Binary(payload))) => { + return serde_json::from_slice(&payload) + .map_err(|error| TransportError::Serialization(error.to_string())); + } + Some(Ok(Message::Ping(payload))) => stream + .send(Message::Pong(payload)) + .await + .map_err(|_| TransportError::Relay("relay pong failed".into()))?, + Some(Ok(Message::Pong(_))) => {} + Some(Ok(Message::Text(_))) => { + return Err(TransportError::Relay( + "relay returned an unexpected text frame".into(), + )); + } + Some(Ok(Message::Close(_))) | None => { + return Err(TransportError::Relay("relay connection closed".into())); + } + Some(Ok(Message::Frame(_))) => {} + Some(Err(_)) => { + return Err(TransportError::Relay("relay receive failed".into())); + } + } + } + } +} diff --git a/packages/networking/src/report.rs b/packages/networking/src/report.rs index 6630160..a4b24d5 100644 --- a/packages/networking/src/report.rs +++ b/packages/networking/src/report.rs @@ -1,8 +1,8 @@ use opencade_protocol::{ - MATCH_REPORT_SCHEMA_VERSION, MatchCandidateKind, MatchReport, MatchReportTransport, - NatMappingState, RoomState, + ALPHA_FAILURE_REPORT_SCHEMA_VERSION, AlphaFailureReport, MATCH_REPORT_SCHEMA_VERSION, + MatchCandidateKind, MatchReport, MatchReportTransport, NatMappingState, RoomState, }; -use serde::Serialize; +use serde::{Serialize, de::DeserializeOwned}; use std::collections::{BTreeMap, BTreeSet}; use std::fs::File; use std::io::Read; @@ -11,13 +11,16 @@ use std::path::Path; pub const ALPHA_MATCH_FRAMES: u32 = 60; pub const MAX_REPORT_BYTES: u64 = 64 * 1024; +type CompatibilityKey = (String, String, &'static str, &'static str, &'static str); +type CompatibilityCounts = (usize, usize); + #[derive(Debug, thiserror::Error)] pub enum ReportReadError { #[error("report could not be read")] Unreadable, #[error("report exceeds 64 KiB")] TooLarge, - #[error("report is not a canonical OpenCade match report")] + #[error("report is not canonical OpenCade alpha evidence")] Invalid, } @@ -32,6 +35,38 @@ impl ReportReadError { } pub fn read_match_report(path: &Path) -> Result { + read_bounded_json(path) +} + +#[derive(Debug)] +pub enum AlphaCampaignEvidence { + Match(MatchReport), + Failure(AlphaFailureReport), +} + +pub fn read_campaign_evidence(path: &Path) -> Result { + let bytes = read_bounded_bytes(path)?; + let value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| ReportReadError::Invalid)?; + match value.get("kind") { + None => serde_json::from_value(value) + .map(AlphaCampaignEvidence::Match) + .map_err(|_| ReportReadError::Invalid), + Some(serde_json::Value::String(kind)) if kind == "attempt_failure" => { + serde_json::from_value(value) + .map(AlphaCampaignEvidence::Failure) + .map_err(|_| ReportReadError::Invalid) + } + Some(_) => Err(ReportReadError::Invalid), + } +} + +fn read_bounded_json(path: &Path) -> Result { + let bytes = read_bounded_bytes(path)?; + serde_json::from_slice(&bytes).map_err(|_| ReportReadError::Invalid) +} + +fn read_bounded_bytes(path: &Path) -> Result, ReportReadError> { let file = File::open(path).map_err(|_| ReportReadError::Unreadable)?; let mut bytes = Vec::new(); file.take(MAX_REPORT_BYTES + 1) @@ -40,7 +75,7 @@ pub fn read_match_report(path: &Path) -> Result { if bytes.len() as u64 > MAX_REPORT_BYTES { return Err(ReportReadError::TooLarge); } - serde_json::from_slice(&bytes).map_err(|_| ReportReadError::Invalid) + Ok(bytes) } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -57,13 +92,14 @@ pub struct MatchVerification { #[derive(Debug, Clone, PartialEq, Serialize)] pub struct AlphaCampaignFailure { pub room_id: String, - pub code: &'static str, + pub code: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct CompatibilityResult { pub game_id: String, pub platform: String, + pub transport: &'static str, pub nat: &'static str, pub candidate: &'static str, pub attempts: usize, @@ -107,6 +143,14 @@ pub enum ReportVerificationError { RoleMismatch, #[error("reports contain different transcript checksums")] ChecksumMismatch, + #[error("reports describe different transports")] + TransportMismatch, + #[error("reports contain different native emulator compatibility fingerprints")] + CompatibilityMismatch, + #[error("{report} report is missing native emulator compatibility evidence")] + CompatibilityMissing { report: &'static str }, + #[error("{report} report contains invalid native emulator compatibility evidence")] + InvalidCompatibility { report: &'static str }, } impl ReportVerificationError { @@ -123,36 +167,64 @@ impl ReportVerificationError { Self::GameMismatch => "game_mismatch", Self::RoleMismatch => "role_mismatch", Self::ChecksumMismatch => "checksum_mismatch", + Self::TransportMismatch => "transport_mismatch", + Self::CompatibilityMismatch => "compatibility_mismatch", + Self::CompatibilityMissing { .. } => "compatibility_missing", + Self::InvalidCompatibility { .. } => "compatibility_invalid", } } } pub fn summarize_match_reports(reports: &[MatchReport]) -> AlphaCampaignSummary { - let mut rooms: BTreeMap<&str, Vec<&MatchReport>> = BTreeMap::new(); + summarize_campaign_evidence(reports, &[]) +} + +pub fn summarize_campaign_evidence( + reports: &[MatchReport], + failure_reports: &[AlphaFailureReport], +) -> AlphaCampaignSummary { + let mut rooms: BTreeMap> = BTreeMap::new(); for report in reports { - rooms.entry(&report.room.id).or_default().push(report); + rooms + .entry(report.room.id.clone()) + .or_default() + .push(report); + } + let mut failure_rooms: BTreeMap> = BTreeMap::new(); + for report in failure_reports { + failure_rooms + .entry(report.room.id.clone()) + .or_default() + .push(report); } + let room_ids: BTreeSet = rooms.keys().chain(failure_rooms.keys()).cloned().collect(); let mut verified = 0; let mut failures = Vec::new(); - let mut compatibility: BTreeMap<(String, String, &'static str, &'static str), (usize, usize)> = - BTreeMap::new(); + let mut compatibility: BTreeMap = BTreeMap::new(); - for (room_id, pair) in &rooms { - let result = if pair.len() == 2 { + for room_id in &room_ids { + let pair = rooms.get(room_id).map(Vec::as_slice).unwrap_or_default(); + let abandoned = failure_rooms + .get(room_id) + .map(Vec::as_slice) + .unwrap_or_default(); + let result = if !abandoned.is_empty() { + failure_result(pair, abandoned) + } else if pair.len() == 2 { verify_match_reports(pair[0], pair[1]) + .map(|_| ()) + .map_err(|error| error.code().to_string()) } else { - Err(ReportVerificationError::PairSize { - reports: pair.len(), - }) + Err("report_pair_invalid".to_string()) }; let is_verified = result.is_ok(); if is_verified { verified += 1; - } else if let Err(error) = result { + } else if let Err(code) = result { failures.push(AlphaCampaignFailure { - room_id: (*room_id).to_string(), - code: error.code(), + room_id: room_id.clone(), + code, }); } @@ -161,6 +233,7 @@ pub fn summarize_match_reports(reports: &[MatchReport]) -> AlphaCampaignSummary let key = ( report.room.game_id.clone(), report.client.platform.clone(), + transport_label(report.probe.transport), nat_label(report.probe.nat), candidate_label(report.probe.candidate), ); @@ -172,9 +245,21 @@ pub fn summarize_match_reports(reports: &[MatchReport]) -> AlphaCampaignSummary } } } + for report in abandoned { + let key = ( + report.room.game_id.clone(), + report.client.platform.clone(), + optional_transport_label(report.transport), + "unknown", + "unknown", + ); + if seen.insert(key.clone()) { + compatibility.entry(key).or_default().0 += 1; + } + } } - let attempts = rooms.len(); + let attempts = room_ids.len(); let success_rate = if attempts == 0 { 0.0 } else { @@ -182,7 +267,7 @@ pub fn summarize_match_reports(reports: &[MatchReport]) -> AlphaCampaignSummary }; AlphaCampaignSummary { schema_version: 1, - reports: reports.len(), + reports: reports.len() + failure_reports.len(), attempts, verified, failed: attempts - verified, @@ -192,19 +277,98 @@ pub fn summarize_match_reports(reports: &[MatchReport]) -> AlphaCampaignSummary compatibility: compatibility .into_iter() .map( - |((game_id, platform, nat, candidate), (attempts, verified))| CompatibilityResult { - game_id, - platform, - nat, - candidate, - attempts, - verified, + |((game_id, platform, transport, nat, candidate), (attempts, verified))| { + CompatibilityResult { + game_id, + platform, + transport, + nat, + candidate, + attempts, + verified, + } }, ) .collect(), } } +fn failure_result( + matches: &[&MatchReport], + failures: &[&AlphaFailureReport], +) -> Result<(), String> { + if !matches.is_empty() { + return Err("evidence_conflict".into()); + } + for report in failures { + validate_failure_report(report).map_err(|error| error.code().to_string())?; + } + if failures + .iter() + .map(|report| report.room.game_id.as_str()) + .collect::>() + .len() + != 1 + { + return Err("evidence_conflict".into()); + } + Err(failures + .iter() + .map(|report| report.error_code.as_str()) + .min() + .unwrap_or("attempt_failed") + .to_string()) +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +enum FailureEvidenceError { + #[error("failure report uses an unsupported schema version")] + UnsupportedSchema, + #[error("failure report has an empty room or game identifier")] + EmptyCorrelation, + #[error("failure report has an invalid error code")] + InvalidCode, +} + +impl FailureEvidenceError { + fn code(&self) -> &'static str { + match self { + Self::UnsupportedSchema => "schema_unsupported", + Self::EmptyCorrelation => "correlation_missing", + Self::InvalidCode => "failure_code_invalid", + } + } +} + +fn validate_failure_report(report: &AlphaFailureReport) -> Result<(), FailureEvidenceError> { + if report.schema_version != ALPHA_FAILURE_REPORT_SCHEMA_VERSION { + return Err(FailureEvidenceError::UnsupportedSchema); + } + if report.room.id.trim().is_empty() || report.room.game_id.trim().is_empty() { + return Err(FailureEvidenceError::EmptyCorrelation); + } + let valid_code = (3..=64).contains(&report.error_code.len()) + && report + .error_code + .bytes() + .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == b'_'); + if !valid_code { + return Err(FailureEvidenceError::InvalidCode); + } + Ok(()) +} + +fn transport_label(value: MatchReportTransport) -> &'static str { + match value { + MatchReportTransport::DirectUdp => "direct_udp", + MatchReportTransport::Relay => "relay", + } +} + +fn optional_transport_label(value: Option) -> &'static str { + value.map(transport_label).unwrap_or("not_selected") +} + fn nat_label(value: Option) -> &'static str { match value { Some(NatMappingState::Open) => "open", @@ -240,18 +404,37 @@ pub fn verify_match_reports( if first.probe.transcript_checksum != second.probe.transcript_checksum { return Err(ReportVerificationError::ChecksumMismatch); } + if first.probe.transport != second.probe.transport { + return Err(ReportVerificationError::TransportMismatch); + } + if first.compatibility != second.compatibility { + return Err(ReportVerificationError::CompatibilityMismatch); + } Ok(MatchVerification { schema_version: MATCH_REPORT_SCHEMA_VERSION, verified: true, room_id: first.room.id.clone(), game_id: first.room.game_id.clone(), - transport: MatchReportTransport::DirectUdp, + transport: first.probe.transport, frames_received: ALPHA_MATCH_FRAMES, transcript_checksum: first.probe.transcript_checksum.clone(), }) } +pub fn verify_playable_match_reports( + first: &MatchReport, + second: &MatchReport, +) -> Result { + if first.compatibility.is_none() { + return Err(ReportVerificationError::CompatibilityMissing { report: "first" }); + } + if second.compatibility.is_none() { + return Err(ReportVerificationError::CompatibilityMissing { report: "second" }); + } + verify_match_reports(first, second) +} + fn validate_report( label: &'static str, report: &MatchReport, @@ -282,6 +465,28 @@ fn validate_report( { return Err(ReportVerificationError::InvalidChecksum { report: label }); } + if let Some(compatibility) = &report.compatibility { + let hashes = [ + &compatibility.executable_sha256, + &compatibility.core_sha256, + &compatibility.content_sha256, + ]; + let valid_hashes = hashes.iter().all(|hash| { + hash.len() == 64 + && hash + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }); + if compatibility.adapter.trim().is_empty() + || compatibility + .emulator_version + .as_ref() + .is_some_and(|version| version.trim().is_empty()) + || !valid_hashes + { + return Err(ReportVerificationError::InvalidCompatibility { report: label }); + } + } Ok(()) } @@ -290,7 +495,8 @@ mod tests { use super::*; use chrono::{TimeZone, Utc}; use opencade_protocol::{ - MatchReportClient, MatchReportProbe, MatchReportRole, MatchReportRoom, + AlphaEvidenceKind, AlphaFailureStage, MatchReportClient, MatchReportCompatibility, + MatchReportProbe, MatchReportRole, MatchReportRoom, }; fn report(role: MatchReportRole) -> MatchReport { @@ -320,6 +526,31 @@ mod tests { platform: "windows".into(), user_agent: "opencade-test".into(), }, + compatibility: None, + } + } + + fn failure_report() -> AlphaFailureReport { + AlphaFailureReport { + schema_version: ALPHA_FAILURE_REPORT_SCHEMA_VERSION, + kind: AlphaEvidenceKind::AttemptFailure, + exported_at: Utc + .with_ymd_and_hms(2026, 8, 24, 12, 0, 0) + .single() + .expect("timestamp"), + room: MatchReportRoom { + id: "failed-room".into(), + game_id: "sfiii3".into(), + state: RoomState::Connecting, + }, + role: MatchReportRole::Host, + stage: AlphaFailureStage::Relay, + error_code: "relay_timeout".into(), + transport: Some(MatchReportTransport::Relay), + client: MatchReportClient { + platform: "windows".into(), + user_agent: "opencade-test".into(), + }, } } @@ -373,6 +604,63 @@ mod tests { ); } + #[test] + fn rejects_transport_or_native_fingerprint_mismatches() { + let host = report(MatchReportRole::Host); + let mut guest = report(MatchReportRole::Guest); + guest.probe.transport = MatchReportTransport::Relay; + assert_eq!( + verify_match_reports(&host, &guest), + Err(ReportVerificationError::TransportMismatch) + ); + + guest = report(MatchReportRole::Guest); + guest.compatibility = Some(MatchReportCompatibility { + adapter: "retroarch_fbneo".into(), + emulator_version: Some("1.22.0".into()), + executable_sha256: "a".repeat(64), + core_sha256: "b".repeat(64), + content_sha256: "c".repeat(64), + }); + assert_eq!( + verify_match_reports(&host, &guest), + Err(ReportVerificationError::CompatibilityMismatch) + ); + } + + #[test] + fn strict_playable_verification_requires_valid_native_evidence() { + let host = report(MatchReportRole::Host); + let guest = report(MatchReportRole::Guest); + assert_eq!( + verify_playable_match_reports(&host, &guest), + Err(ReportVerificationError::CompatibilityMissing { report: "first" }) + ); + + let compatibility = MatchReportCompatibility { + adapter: "retroarch_fbneo".into(), + emulator_version: Some("1.22.0".into()), + executable_sha256: "a".repeat(64), + core_sha256: "b".repeat(64), + content_sha256: "c".repeat(64), + }; + let mut host = host; + let mut guest = guest; + host.compatibility = Some(compatibility.clone()); + guest.compatibility = Some(compatibility); + assert!(verify_playable_match_reports(&host, &guest).is_ok()); + + guest + .compatibility + .as_mut() + .expect("compatibility") + .core_sha256 = "NOT-A-HASH".into(); + assert_eq!( + verify_playable_match_reports(&host, &guest), + Err(ReportVerificationError::InvalidCompatibility { report: "second" }) + ); + } + #[test] fn campaign_gate_accepts_eight_of_ten_verified_attempts() { let mut reports = Vec::new(); @@ -395,6 +683,71 @@ mod tests { assert_eq!(summary.success_rate, 0.8); assert!(summary.gate_passed); assert_eq!(summary.compatibility.len(), 1); + assert_eq!(summary.compatibility[0].transport, "direct_udp"); assert_eq!(summary.compatibility[0].verified, 8); } + + #[test] + fn campaign_matrix_separates_direct_and_relay_attempts() { + let mut reports = Vec::new(); + for (room_id, transport) in [ + ("direct-room", MatchReportTransport::DirectUdp), + ("relay-room", MatchReportTransport::Relay), + ] { + for role in [MatchReportRole::Host, MatchReportRole::Guest] { + let mut value = report(role); + value.room.id = room_id.into(); + value.probe.transport = transport; + if transport == MatchReportTransport::Relay { + value.probe.candidate = None; + value.probe.punch_attempts = None; + } + reports.push(value); + } + } + + let summary = summarize_match_reports(&reports); + assert_eq!(summary.verified, 2); + assert_eq!(summary.compatibility.len(), 2); + assert_eq!(summary.compatibility[0].transport, "direct_udp"); + assert_eq!(summary.compatibility[1].transport, "relay"); + } + + #[test] + fn campaign_counts_an_abandoned_room_without_success_reports() { + let summary = summarize_campaign_evidence(&[], &[failure_report()]); + + assert_eq!(summary.reports, 1); + assert_eq!(summary.attempts, 1); + assert_eq!(summary.verified, 0); + assert_eq!(summary.failed, 1); + assert_eq!(summary.failures[0].code, "relay_timeout"); + assert_eq!(summary.compatibility[0].transport, "relay"); + assert_eq!(summary.compatibility[0].verified, 0); + } + + #[test] + fn campaign_rejects_success_and_failure_evidence_for_one_room() { + let host = report(MatchReportRole::Host); + let guest = report(MatchReportRole::Guest); + let mut failure = failure_report(); + failure.room.id = host.room.id.clone(); + + let summary = summarize_campaign_evidence(&[host, guest], &[failure]); + + assert_eq!(summary.attempts, 1); + assert_eq!(summary.verified, 0); + assert_eq!(summary.failures[0].code, "evidence_conflict"); + } + + #[test] + fn campaign_rejects_unstable_failure_codes() { + let mut failure = failure_report(); + failure.error_code = "Relay timed out at C:\\Users\\tester".into(); + + let summary = summarize_campaign_evidence(&[], &[failure]); + + assert_eq!(summary.failed, 1); + assert_eq!(summary.failures[0].code, "failure_code_invalid"); + } } diff --git a/packages/networking/tests/report_verifier.rs b/packages/networking/tests/report_verifier.rs index 425af5f..80b1287 100644 --- a/packages/networking/tests/report_verifier.rs +++ b/packages/networking/tests/report_verifier.rs @@ -1,5 +1,6 @@ use chrono::{TimeZone, Utc}; use opencade_protocol::{ + ALPHA_FAILURE_REPORT_SCHEMA_VERSION, AlphaEvidenceKind, AlphaFailureReport, AlphaFailureStage, MATCH_REPORT_SCHEMA_VERSION, MatchReport, MatchReportClient, MatchReportProbe, MatchReportRole, MatchReportRoom, MatchReportTransport, RoomState, }; @@ -35,6 +36,31 @@ fn report(role: MatchReportRole) -> MatchReport { platform: "windows".into(), user_agent: "opencade-test".into(), }, + compatibility: None, + } +} + +fn failure_report(room_id: String) -> AlphaFailureReport { + AlphaFailureReport { + schema_version: ALPHA_FAILURE_REPORT_SCHEMA_VERSION, + kind: AlphaEvidenceKind::AttemptFailure, + exported_at: Utc + .with_ymd_and_hms(2026, 8, 24, 12, 0, 0) + .single() + .expect("timestamp"), + room: MatchReportRoom { + id: room_id, + game_id: "sfiii3".into(), + state: RoomState::Connecting, + }, + role: MatchReportRole::Host, + stage: AlphaFailureStage::Relay, + error_code: "relay_timeout".into(), + transport: Some(MatchReportTransport::Relay), + client: MatchReportClient { + platform: "windows".into(), + user_agent: "opencade-test".into(), + }, } } @@ -77,6 +103,17 @@ fn verifier_cli_returns_machine_readable_success_and_failure() { assert_eq!(output["verified"], true); assert_eq!(output["room_id"], "room-1"); + let strict = Command::new(env!("CARGO_BIN_EXE_opencade-match-verify")) + .arg("--require-compatibility") + .arg(&host_path) + .arg(&guest_path) + .output() + .expect("run strict verifier"); + assert_eq!(strict.status.code(), Some(1)); + let output: serde_json::Value = + serde_json::from_slice(&strict.stderr).expect("strict failure output JSON"); + assert_eq!(output["code"], "compatibility_missing"); + let mut mismatched = report(MatchReportRole::Guest); mismatched.probe.transcript_checksum = "aaaaaaaaaaaaaaaa".into(); fs::write( @@ -114,21 +151,24 @@ fn summary_cli_derives_eight_of_ten_campaign_gate() { let directory = fixture_dir(); fs::create_dir(&directory).expect("fixture directory"); - for attempt in 0..10 { + for attempt in 0..8 { let room_id = format!("room-{attempt}"); let mut host = report(MatchReportRole::Host); host.room.id.clone_from(&room_id); let mut guest = report(MatchReportRole::Guest); guest.room.id = room_id; - if attempt >= 8 { - guest.probe.transcript_checksum = "aaaaaaaaaaaaaaaa".into(); - } for (role, report) in [("host", host), ("guest", guest)] { let path = directory.join(format!("attempt-{attempt:02}-{role}.json")); fs::write(path, serde_json::to_vec(&report).expect("report JSON")) .expect("campaign report"); } } + for attempt in 8..10 { + let path = directory.join(format!("attempt-{attempt:02}-failure.json")); + let failure = failure_report(format!("room-{attempt}")); + fs::write(path, serde_json::to_vec(&failure).expect("failure JSON")) + .expect("campaign failure report"); + } let result = Command::new(env!("CARGO_BIN_EXE_opencade-alpha-summary")) .arg(&directory) @@ -138,11 +178,36 @@ fn summary_cli_derives_eight_of_ten_campaign_gate() { let output: serde_json::Value = serde_json::from_slice(&result.stdout).expect("summary output JSON"); assert_eq!(output["attempts"], 10); + assert_eq!(output["reports"], 18); assert_eq!(output["verified"], 8); assert_eq!(output["success_rate"], 0.8); assert_eq!(output["gate_passed"], true); - assert_eq!(output["compatibility"][0]["attempts"], 10); + assert_eq!(output["compatibility"][0]["attempts"], 8); assert_eq!(output["compatibility"][0]["verified"], 8); + assert_eq!(output["compatibility"][0]["transport"], "direct_udp"); + assert_eq!(output["compatibility"][1]["attempts"], 2); + assert_eq!(output["compatibility"][1]["verified"], 0); + assert_eq!(output["compatibility"][1]["transport"], "relay"); + + fs::remove_dir_all(directory).expect("remove fixture directory"); +} + +#[test] +fn summary_cli_rejects_unknown_evidence_kinds() { + let directory = fixture_dir(); + fs::create_dir(&directory).expect("fixture directory"); + fs::write( + directory.join("unknown.json"), + br#"{"schema_version":1,"kind":"future_evidence"}"#, + ) + .expect("unknown evidence"); + + let result = Command::new(env!("CARGO_BIN_EXE_opencade-alpha-summary")) + .arg(&directory) + .output() + .expect("run campaign summary"); + assert_eq!(result.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&result.stderr).contains("report_invalid")); fs::remove_dir_all(directory).expect("remove fixture directory"); } diff --git a/packages/protocol/src/generated/AlphaEvidenceKind.ts b/packages/protocol/src/generated/AlphaEvidenceKind.ts new file mode 100644 index 0000000..99f3cef --- /dev/null +++ b/packages/protocol/src/generated/AlphaEvidenceKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Discriminator that keeps failure evidence distinct from successful match reports. + */ +export type AlphaEvidenceKind = "attempt_failure"; diff --git a/packages/protocol/src/generated/AlphaFailureReport.ts b/packages/protocol/src/generated/AlphaFailureReport.ts new file mode 100644 index 0000000..eb3b1ca --- /dev/null +++ b/packages/protocol/src/generated/AlphaFailureReport.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AlphaEvidenceKind } from "./AlphaEvidenceKind"; +import type { AlphaFailureStage } from "./AlphaFailureStage"; +import type { MatchReportClient } from "./MatchReportClient"; +import type { MatchReportRole } from "./MatchReportRole"; +import type { MatchReportRoom } from "./MatchReportRoom"; +import type { MatchReportTransport } from "./MatchReportTransport"; + +/** + * Canonical redacted evidence for an alpha attempt that did not complete. + */ +export type AlphaFailureReport = { + schema_version: number; + kind: AlphaEvidenceKind; + exported_at: string; + room: MatchReportRoom; + role: MatchReportRole; + stage: AlphaFailureStage; + error_code: string; + transport: MatchReportTransport | null; + client: MatchReportClient; +}; diff --git a/packages/protocol/src/generated/AlphaFailureStage.ts b/packages/protocol/src/generated/AlphaFailureStage.ts new file mode 100644 index 0000000..f32a9cd --- /dev/null +++ b/packages/protocol/src/generated/AlphaFailureStage.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Stable stages for privacy-minimized evidence from an abandoned alpha attempt. + */ +export type AlphaFailureStage = + | "endpoint_reservation" + | "direct_udp" + | "relay_ticket" + | "relay" + | "peer_transcript" + | "room_transition" + | "native_launch"; diff --git a/packages/protocol/src/generated/MatchReport.ts b/packages/protocol/src/generated/MatchReport.ts index 91231c4..844d9b1 100644 --- a/packages/protocol/src/generated/MatchReport.ts +++ b/packages/protocol/src/generated/MatchReport.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { MatchReportClient } from "./MatchReportClient"; +import type { MatchReportCompatibility } from "./MatchReportCompatibility"; import type { MatchReportProbe } from "./MatchReportProbe"; import type { MatchReportRoom } from "./MatchReportRoom"; @@ -12,4 +13,5 @@ export type MatchReport = { room: MatchReportRoom; probe: MatchReportProbe; client: MatchReportClient; + compatibility?: MatchReportCompatibility | null; }; diff --git a/packages/protocol/src/generated/MatchReportCompatibility.ts b/packages/protocol/src/generated/MatchReportCompatibility.ts new file mode 100644 index 0000000..0e55622 --- /dev/null +++ b/packages/protocol/src/generated/MatchReportCompatibility.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Optional, privacy-safe hashes that let both peers prove identical native emulator inputs. + */ +export type MatchReportCompatibility = { + adapter: string; + emulator_version: string | null; + executable_sha256: string; + core_sha256: string; + content_sha256: string; +}; diff --git a/packages/protocol/src/generated/MatchReportTransport.ts b/packages/protocol/src/generated/MatchReportTransport.ts index c7a5c78..497566b 100644 --- a/packages/protocol/src/generated/MatchReportTransport.ts +++ b/packages/protocol/src/generated/MatchReportTransport.ts @@ -3,4 +3,4 @@ /** * Transport values accepted by the v1 alpha evidence contract. */ -export type MatchReportTransport = "direct_udp"; +export type MatchReportTransport = "direct_udp" | "relay"; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 7c5f371..77cf2c3 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -10,6 +10,10 @@ export type { MatchProbeCompletedPayload, MatchReport, MatchReportClient, + MatchReportCompatibility, + AlphaEvidenceKind, + AlphaFailureReport, + AlphaFailureStage, MatchReportProbe, MatchReportRole, MatchReportRoom, diff --git a/packages/protocol/src/lib.rs b/packages/protocol/src/lib.rs index 7920170..9d6140a 100644 --- a/packages/protocol/src/lib.rs +++ b/packages/protocol/src/lib.rs @@ -18,6 +18,9 @@ pub const PROTOCOL_VERSION: &str = "1.0"; /// Canonical schema version for redacted LAN alpha evidence reports. pub const MATCH_REPORT_SCHEMA_VERSION: u8 = 1; +/// Canonical schema version for redacted alpha failure evidence. +pub const ALPHA_FAILURE_REPORT_SCHEMA_VERSION: u8 = 1; + /// Returns `true` if the supplied version string is supported. /// /// Accepts both canonical `"1.0"` and compat `"1"`. @@ -221,6 +224,7 @@ pub enum MatchReportRole { #[serde(rename_all = "snake_case")] pub enum MatchReportTransport { DirectUdp, + Relay, } /// Privacy-minimized room correlation included in an alpha report. @@ -255,6 +259,54 @@ pub struct MatchReportClient { pub user_agent: String, } +/// Optional, privacy-safe hashes that let both peers prove identical native emulator inputs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/MatchReportCompatibility.ts")] +pub struct MatchReportCompatibility { + pub adapter: String, + pub emulator_version: Option, + pub executable_sha256: String, + pub core_sha256: String, + pub content_sha256: String, +} + +/// Stable stages for privacy-minimized evidence from an abandoned alpha attempt. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/AlphaFailureStage.ts")] +#[serde(rename_all = "snake_case")] +pub enum AlphaFailureStage { + EndpointReservation, + DirectUdp, + RelayTicket, + Relay, + PeerTranscript, + RoomTransition, + NativeLaunch, +} + +/// Discriminator that keeps failure evidence distinct from successful match reports. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/AlphaEvidenceKind.ts")] +#[serde(rename_all = "snake_case")] +pub enum AlphaEvidenceKind { + AttemptFailure, +} + +/// Canonical redacted evidence for an alpha attempt that did not complete. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export, export_to = "../src/generated/AlphaFailureReport.ts")] +pub struct AlphaFailureReport { + pub schema_version: u8, + pub kind: AlphaEvidenceKind, + pub exported_at: DateTime, + pub room: MatchReportRoom, + pub role: MatchReportRole, + pub stage: AlphaFailureStage, + pub error_code: String, + pub transport: Option, + pub client: MatchReportClient, +} + /// Canonical, redacted evidence emitted by both the desktop and standalone LAN probe. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] #[ts(export, export_to = "../src/generated/MatchReport.ts")] @@ -264,6 +316,8 @@ pub struct MatchReport { pub room: MatchReportRoom, pub probe: MatchReportProbe, pub client: MatchReportClient, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compatibility: Option, } /// Room lifecycle states. Serialized as `snake_case` strings. /// ARCHITECTURE.md §9 DB stores WAITING/READY/PLAYING/FINISHED/CANCELLED (upper); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index cb4909c..ddf6d1f 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6,6 +6,10 @@ import type { NatMappingState } from "./generated/NatMappingState.js"; import type { MatchProbeCompletedPayload } from "./generated/MatchProbeCompletedPayload.js"; import type { MatchReport } from "./generated/MatchReport.js"; import type { MatchReportClient } from "./generated/MatchReportClient.js"; +import type { MatchReportCompatibility } from "./generated/MatchReportCompatibility.js"; +import type { AlphaEvidenceKind } from "./generated/AlphaEvidenceKind.js"; +import type { AlphaFailureReport } from "./generated/AlphaFailureReport.js"; +import type { AlphaFailureStage } from "./generated/AlphaFailureStage.js"; import type { MatchReportProbe } from "./generated/MatchReportProbe.js"; import type { MatchReportRoom } from "./generated/MatchReportRoom.js"; import type { PresencePayload } from "./generated/PresencePayload.js"; @@ -23,6 +27,10 @@ export type { MatchProbeCompletedPayload, MatchReport, MatchReportClient, + MatchReportCompatibility, + AlphaEvidenceKind, + AlphaFailureReport, + AlphaFailureStage, MatchReportProbe, MatchReportRoom, PresencePayload, diff --git a/packages/shared/Cargo.toml b/packages/shared/Cargo.toml index 2cead71..49356e0 100644 --- a/packages/shared/Cargo.toml +++ b/packages/shared/Cargo.toml @@ -2,7 +2,7 @@ name = "opencade-shared" version = "0.1.0" edition = "2024" -description = "OpenCade shared utilities (placeholder)" +description = "OpenCade shared security and service utilities" license = "Apache-2.0" repository = "https://github.com/opencade/opencade" rust-version = "1.98" @@ -10,3 +10,6 @@ rust-version = "1.98" [dependencies] serde = { version = "1.0.229", features = ["derive"] } thiserror = "2.0.20" +hmac = "0.13.0" +sha2 = "0.11.0" +hex = "0.4.3" diff --git a/packages/shared/src/lib.rs b/packages/shared/src/lib.rs index 564422d..b286e50 100644 --- a/packages/shared/src/lib.rs +++ b/packages/shared/src/lib.rs @@ -1 +1,131 @@ -// opencade-shared placeholder. +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +pub const MAX_RELAY_TICKET_LIFETIME_SECONDS: i64 = 300; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayTicket { + pub room_id: String, + pub user_id: String, + pub expires_at: i64, + pub signature: String, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RelayTicketError { + #[error("relay ticket identifiers must not be empty")] + EmptyIdentifier, + #[error("relay ticket secret must contain at least 32 bytes")] + WeakSecret, + #[error("relay ticket has expired")] + Expired, + #[error("relay ticket lifetime exceeds the permitted maximum")] + ExcessiveLifetime, + #[error("relay ticket signature is invalid")] + InvalidSignature, +} + +impl RelayTicket { + pub fn issue( + secret: &[u8], + room_id: &str, + user_id: &str, + expires_at: i64, + ) -> Result { + validate_inputs(secret, room_id, user_id)?; + let signature = signature(secret, room_id, user_id, expires_at)?; + Ok(Self { + room_id: room_id.to_owned(), + user_id: user_id.to_owned(), + expires_at, + signature: hex::encode(signature), + }) + } + + pub fn verify(&self, secret: &[u8], now: i64) -> Result<(), RelayTicketError> { + validate_inputs(secret, &self.room_id, &self.user_id)?; + if self.expires_at < now { + return Err(RelayTicketError::Expired); + } + if self.expires_at - now > MAX_RELAY_TICKET_LIFETIME_SECONDS { + return Err(RelayTicketError::ExcessiveLifetime); + } + let provided = + hex::decode(&self.signature).map_err(|_| RelayTicketError::InvalidSignature)?; + let mut mac = relay_mac(secret)?; + mac.update(&canonical_claims( + &self.room_id, + &self.user_id, + self.expires_at, + )); + mac.verify_slice(&provided) + .map_err(|_| RelayTicketError::InvalidSignature) + } +} + +fn validate_inputs(secret: &[u8], room_id: &str, user_id: &str) -> Result<(), RelayTicketError> { + if secret.len() < 32 { + return Err(RelayTicketError::WeakSecret); + } + if room_id.trim().is_empty() || user_id.trim().is_empty() { + return Err(RelayTicketError::EmptyIdentifier); + } + Ok(()) +} + +fn relay_mac(secret: &[u8]) -> Result, RelayTicketError> { + Hmac::::new_from_slice(secret).map_err(|_| RelayTicketError::WeakSecret) +} + +fn signature( + secret: &[u8], + room_id: &str, + user_id: &str, + expires_at: i64, +) -> Result, RelayTicketError> { + let mut mac = relay_mac(secret)?; + mac.update(&canonical_claims(room_id, user_id, expires_at)); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn canonical_claims(room_id: &str, user_id: &str, expires_at: i64) -> Vec { + format!( + "{}:{room_id}:{}:{user_id}:{expires_at}", + room_id.len(), + user_id.len() + ) + .into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &[u8] = b"relay-test-secret-at-least-32-bytes-long"; + + #[test] + fn issues_and_verifies_a_short_lived_ticket() { + let ticket = RelayTicket::issue(SECRET, "room", "user", 1_120).expect("ticket"); + assert_eq!(ticket.verify(SECRET, 1_000), Ok(())); + } + + #[test] + fn rejects_tampering_expiry_and_excessive_lifetime() { + let mut ticket = RelayTicket::issue(SECRET, "room", "user", 1_120).expect("ticket"); + ticket.room_id = "other".into(); + assert_eq!( + ticket.verify(SECRET, 1_000), + Err(RelayTicketError::InvalidSignature) + ); + + let ticket = RelayTicket::issue(SECRET, "room", "user", 999).expect("ticket"); + assert_eq!(ticket.verify(SECRET, 1_000), Err(RelayTicketError::Expired)); + + let ticket = RelayTicket::issue(SECRET, "room", "user", 2_000).expect("ticket"); + assert_eq!( + ticket.verify(SECRET, 1_000), + Err(RelayTicketError::ExcessiveLifetime) + ); + } +} diff --git a/scripts/alpha/OpenCade-Alpha.ps1 b/scripts/alpha/OpenCade-Alpha.ps1 new file mode 100644 index 0000000..f897926 --- /dev/null +++ b/scripts/alpha/OpenCade-Alpha.ps1 @@ -0,0 +1,207 @@ +[CmdletBinding()] +param( + [ValidateSet("Package", "Verify", "Doctor", "Launch")] + [string]$Mode = "Doctor", + [string]$KitRoot = ".", + [string]$ApiUrl, + [string]$RetroArchRoot, + [string]$StunServer +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$requiredKitFiles = @( + "opencade-client.exe", + "opencade-match-probe.exe", + "opencade-match-verify.exe", + "opencade-alpha-summary.exe", + "OpenCade-Alpha.ps1", + "README.txt", + "RETROARCH_TEST.md", + "MATCH_REPORT_TEMPLATE.md" +) + +function Resolve-KitRoot { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -ItemType Directory -Path $Path -Force | Out-Null + } + return (Resolve-Path -LiteralPath $Path).Path +} + +function Write-Checksums { + param([string]$Root) + + $lines = Get-ChildItem -LiteralPath $Root -File | + Where-Object { $_.Name -ne "SHA256SUMS.txt" } | + Sort-Object Name | + ForEach-Object { + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant() + "$hash *$($_.Name)" + } + $lines | Set-Content -LiteralPath (Join-Path $Root "SHA256SUMS.txt") -Encoding ascii +} + +function Package-Kit { + param([string]$Root) + + $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path + $sources = @{ + "opencade-client.exe" = "apps\client\src-tauri\target\release\opencade-client.exe" + "opencade-match-probe.exe" = "target\release\opencade-match-probe.exe" + "opencade-match-verify.exe" = "target\release\opencade-match-verify.exe" + "opencade-alpha-summary.exe" = "target\release\opencade-alpha-summary.exe" + "OpenCade-Alpha.ps1" = "scripts\alpha\OpenCade-Alpha.ps1" + "README.txt" = "scripts\alpha\README.txt" + "RETROARCH_TEST.md" = "docs\alpha\RETROARCH_TEST.md" + "MATCH_REPORT_TEMPLATE.md" = "docs\alpha\MATCH_REPORT_TEMPLATE.md" + } + + foreach ($destination in $sources.Keys) { + $source = Join-Path $repositoryRoot $sources[$destination] + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Required package input is missing: $source" + } + Copy-Item -LiteralPath $source -Destination (Join-Path $Root $destination) -Force + } + + Write-Checksums -Root $Root + Write-Host "Packaged OpenCade alpha kit at $Root" +} + +function Verify-Kit { + param([string]$Root) + + foreach ($name in $requiredKitFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $Root $name) -PathType Leaf)) { + throw "Alpha kit is incomplete: $name is missing" + } + } + + $checksumPath = Join-Path $Root "SHA256SUMS.txt" + if (-not (Test-Path -LiteralPath $checksumPath -PathType Leaf)) { + throw "Alpha kit is incomplete: SHA256SUMS.txt is missing" + } + + $verifiedNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($line in Get-Content -LiteralPath $checksumPath) { + if ($line -notmatch '^([0-9a-fA-F]{64}) \*([^\\/]+)$') { + throw "Invalid checksum entry: $line" + } + $expected = $Matches[1].ToLowerInvariant() + $name = $Matches[2] + if (-not $verifiedNames.Add($name)) { + throw "Duplicate checksum entry: $name" + } + $path = Join-Path $Root $name + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Checksummed file is missing: $name" + } + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + throw "Checksum mismatch: $name" + } + } + + foreach ($name in $requiredKitFiles) { + if (-not $verifiedNames.Contains($name)) { + throw "Required file is not checksummed: $name" + } + } + if ($verifiedNames.Count -ne $requiredKitFiles.Count) { + throw "Expected $($requiredKitFiles.Count) checksummed files, verified $($verifiedNames.Count)" + } + Write-Host "Verified $($verifiedNames.Count) alpha-kit files." +} + +function Assert-ApiUrl { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "-ApiUrl is required for Doctor and Launch modes" + } + $uri = [Uri]$Value + if ($uri.Scheme -notin @("http", "https") -or -not $uri.IsAbsoluteUri) { + throw "ApiUrl must be an absolute HTTP or HTTPS URL" + } + if ($uri.UserInfo -or $uri.Query -or $uri.Fragment) { + throw "ApiUrl must not contain credentials, a query, or a fragment" + } + return $Value.TrimEnd('/') +} + +function Assert-StunServer { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return + } + if ($Value -notmatch '^((?:\d{1,3}\.){3}\d{1,3}):(\d{1,5})$') { + throw "StunServer must be a numeric IPv4 address and port" + } + $address = $null + $port = [int]$Matches[2] + if (-not [Net.IPAddress]::TryParse($Matches[1], [ref]$address) -or $port -lt 1 -or $port -gt 65535) { + throw "StunServer must be a valid numeric IPv4 address and port" + } +} + +function Invoke-Doctor { + param([string]$Root) + + Verify-Kit -Root $Root + $normalizedApiUrl = Assert-ApiUrl -Value $ApiUrl + Assert-StunServer -Value $StunServer + + if ([string]::IsNullOrWhiteSpace($RetroArchRoot)) { + throw "-RetroArchRoot is required for Doctor and Launch modes" + } + $retroRoot = (Resolve-Path -LiteralPath $RetroArchRoot).Path + foreach ($relativePath in @("retroarch.exe", "cores\fbneo_libretro.dll", "VERSION.txt", "ROMs")) { + if (-not (Test-Path -LiteralPath (Join-Path $retroRoot $relativePath))) { + throw "RetroArch layout is incomplete: $relativePath is missing" + } + } + + foreach ($endpoint in @("health", "ready")) { + $response = Invoke-WebRequest -UseBasicParsing -Uri "$normalizedApiUrl/$endpoint" -TimeoutSec 10 + if ($response.StatusCode -ne 200) { + throw "$endpoint returned HTTP $($response.StatusCode)" + } + } + + $reports = Join-Path $Root "reports" + New-Item -ItemType Directory -Path $reports -Force | Out-Null + Write-Host "Alpha doctor passed. Reports directory: $reports" +} + +$resolvedKitRoot = Resolve-KitRoot -Path $KitRoot + +switch ($Mode) { + "Package" { + Package-Kit -Root $resolvedKitRoot + Verify-Kit -Root $resolvedKitRoot + } + "Verify" { + Verify-Kit -Root $resolvedKitRoot + } + "Doctor" { + Invoke-Doctor -Root $resolvedKitRoot + } + "Launch" { + Invoke-Doctor -Root $resolvedKitRoot + $env:OPENCADE_API_URL = (Assert-ApiUrl -Value $ApiUrl) + $env:OPENCADE_RETROARCH_ROOT = (Resolve-Path -LiteralPath $RetroArchRoot).Path + if ([string]::IsNullOrWhiteSpace($StunServer)) { + Remove-Item Env:OPENCADE_STUN_SERVER -ErrorAction SilentlyContinue + } else { + $env:OPENCADE_STUN_SERVER = $StunServer + } + & (Join-Path $resolvedKitRoot "opencade-client.exe") + if ($LASTEXITCODE -ne 0) { + throw "OpenCade client exited with code $LASTEXITCODE" + } + } +} diff --git a/scripts/alpha/README.txt b/scripts/alpha/README.txt new file mode 100644 index 0000000..0ec0c2f --- /dev/null +++ b/scripts/alpha/README.txt @@ -0,0 +1,28 @@ +OpenCade Windows Alpha Kit +========================== + +This is an unsigned test build. It contains no emulator, core, BIOS, or ROM content. + +1. Verify the downloaded kit: + + powershell -ExecutionPolicy Bypass -File .\OpenCade-Alpha.ps1 -Mode Verify + +2. Prepare the user-supplied RetroArch layout described in RETROARCH_TEST.md. + +3. Validate the machine, server, and RetroArch layout: + + powershell -ExecutionPolicy Bypass -File .\OpenCade-Alpha.ps1 -Mode Doctor ` + -ApiUrl https://alpha.example.com ` + -RetroArchRoot C:\OpenCadeAlpha\retroarch + +4. Launch with the same checks and runtime configuration: + + powershell -ExecutionPolicy Bypass -File .\OpenCade-Alpha.ps1 -Mode Launch ` + -ApiUrl https://alpha.example.com ` + -RetroArchRoot C:\OpenCadeAlpha\retroarch ` + -StunServer 203.0.113.10:3478 + +Export host and guest reports into the reports directory. If an attempt is abandoned, export its +failure evidence so it still counts against the campaign gate. Never include emulator binaries, +ROMs, credentials, diagnostic messages, local paths, endpoints, or session material in an issue or +community post. diff --git a/services/relay/Cargo.toml b/services/relay/Cargo.toml index 82592c1..4367dc3 100644 --- a/services/relay/Cargo.toml +++ b/services/relay/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" rust-version = "1.98" license = "Apache-2.0" -description = "OpenCade relay service (WS TURN-like fallback)" +description = "OpenCade authenticated WebSocket relay fallback" repository = "https://github.com/opencade/opencade" [dependencies] @@ -17,4 +17,8 @@ serde_json = "1.0.151" dashmap = "6.2.1" futures = "0.3.34" opencade-protocol = { path = "../../packages/protocol" } +opencade-shared = { path = "../../packages/shared" } uuid = { version = "1.25.0", features = ["v4"] } + +[dev-dependencies] +tokio-tungstenite = "0.30.0" diff --git a/services/relay/Dockerfile b/services/relay/Dockerfile index 4a73ca1..74bae92 100644 --- a/services/relay/Dockerfile +++ b/services/relay/Dockerfile @@ -13,11 +13,12 @@ COPY packages/emulator-sdk/Cargo.toml packages/emulator-sdk/Cargo.toml COPY packages/shared/Cargo.toml packages/shared/Cargo.toml COPY packages/game-definitions/Cargo.toml packages/game-definitions/Cargo.toml COPY adapters/fbneo/Cargo.toml adapters/fbneo/Cargo.toml +COPY adapters/retroarch/Cargo.toml adapters/retroarch/Cargo.toml COPY services/relay/Cargo.toml services/relay/Cargo.toml # Create stub sources so workspace resolves RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ - packages/emulator-sdk/src packages/shared/src packages/game-definitions/src adapters/fbneo/src services/relay/src \ + packages/emulator-sdk/src packages/shared/src packages/game-definitions/src adapters/fbneo/src adapters/retroarch/src services/relay/src \ && echo "fn main() {}" > apps/server/src/main.rs \ && echo "" > packages/protocol/src/lib.rs \ && echo "" > packages/networking/src/lib.rs \ @@ -25,13 +26,15 @@ RUN mkdir -p apps/server/src packages/protocol/src packages/networking/src \ && echo "" > packages/shared/src/lib.rs \ && echo "" > packages/game-definitions/src/lib.rs \ && echo "" > adapters/fbneo/src/lib.rs \ + && echo "" > adapters/retroarch/src/lib.rs \ && echo "fn main() {}" > services/relay/src/main.rs \ && cargo build --release --locked -p opencade-relay # Copy real sources COPY . . -RUN cargo build --release --locked -p opencade-relay \ +RUN cargo clean --release -p opencade-relay -p opencade-protocol -p opencade-shared \ + && cargo build --release --locked -p opencade-relay \ && test -x target/release/opencade-relay # --- runtime --------------------------------------------------------------- @@ -47,11 +50,9 @@ COPY --from=builder /app/target/release/opencade-relay /usr/local/bin/opencade-r USER opencade EXPOSE 8081 -EXPOSE 3478 ENV RUST_LOG=info ENV PORT=8081 -ENV RELAY_PORT=3478 HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=10s \ CMD wget -qO- http://localhost:8081/health || exit 1 diff --git a/services/relay/README.md b/services/relay/README.md index aebf5ce..bfc9828 100644 --- a/services/relay/README.md +++ b/services/relay/README.md @@ -1,6 +1,7 @@ # opencade-relay -TURN-like WebSocket relay fallback for OpenCade. +Authenticated WebSocket readiness-probe relay fallback for OpenCade. It is not a TURN server and +does not transparently relay RetroArch's native netplay connection. ## Run @@ -8,21 +9,22 @@ TURN-like WebSocket relay fallback for OpenCade. cargo run -p opencade-relay # env: # PORT=8081 # HTTP/health port -# RELAY_PORT=3478 # TURN port (exposed via health) # RUST_LOG=info -# RELAY_HOST= # optional +# RELAY_AUTH_SECRET= # required; same 32-byte-or-longer secret as opencade-server # OPENCADE_ENV=production # json logs if production, else pretty ``` ## Endpoints -- `GET /health` → `{status:"ok", version:"0.1.0", relay_port}` +- `GET /health` → `{status:"ok", version:"0.1.0"}` - `GET /ready` → `{status:"ok"}` (DB-less) -- `WS /relay?room_id=` → opaque frame relay between peers in same `room_id` bucket; validates `opencade-protocol` Envelope version if present, forwards otherwise. +- `WS /relay?room_id=...&user_id=...&expires_at=...&signature=...` → accepts a short-lived ticket + issued by `POST /api/v1/rooms/:id/relay-ticket`; fixes the socket to that room, permits at most two + users, and forwards bounded binary frames or valid room-scoped protocol envelopes. ## Docker ```bash docker compose up relay -# exposes 8081 (health) and 3478/udp+tcp +# exposes 8081 for health and authenticated WebSocket relay traffic ``` diff --git a/services/relay/src/main.rs b/services/relay/src/main.rs index 3dc01aa..49c0214 100644 --- a/services/relay/src/main.rs +++ b/services/relay/src/main.rs @@ -10,8 +10,14 @@ use axum::{ }; use dashmap::DashMap; use futures::{SinkExt, StreamExt}; +use opencade_shared::RelayTicket; use serde_json::{Value, json}; -use std::{collections::HashMap, env, net::SocketAddr, sync::Arc}; +use std::{ + env, + net::SocketAddr, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; use tokio::sync::mpsc; use tracing::{info, warn}; use tracing_subscriber::EnvFilter; @@ -19,43 +25,69 @@ use uuid::Uuid; use opencade_protocol::{Envelope, is_supported_version}; -#[derive(Debug, Clone)] +#[derive(Clone)] struct Config { port: u16, - relay_port: u16, - relay_host: Option, rust_log: String, + auth_secret: String, +} + +impl std::fmt::Debug for Config { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Config") + .field("port", &self.port) + .field("rust_log", &self.rust_log) + .field("auth_secret", &"") + .finish() + } } impl Config { - fn from_env() -> Self { - let relay_port = env::var("RELAY_PORT") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(3478); - let port = env::var("PORT") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(8081); + fn from_env() -> Result { + let port = match env::var("PORT") { + Ok(value) => value + .parse::() + .map_err(|_| "PORT must be a valid TCP port".to_string())?, + Err(env::VarError::NotPresent) => 8081, + Err(env::VarError::NotUnicode(_)) => return Err("PORT must be valid Unicode".into()), + }; let rust_log = env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); - let relay_host = env::var("RELAY_HOST").ok().filter(|s| !s.trim().is_empty()); - Self { + let auth_secret = env::var("RELAY_AUTH_SECRET") + .map_err(|_| "RELAY_AUTH_SECRET is required".to_string())?; + if auth_secret.len() < 32 { + return Err("RELAY_AUTH_SECRET must contain at least 32 characters".into()); + } + Ok(Self { port, - relay_port, - relay_host, rust_log, - } + auth_secret, + }) } } #[derive(Debug)] struct RelayState { config: Config, - rooms: DashMap>>, + rooms: DashMap>, +} + +#[derive(Debug, Clone)] +struct RelayPeer { + connection_id: Uuid, + sender: mpsc::Sender, } type SharedState = Arc; +fn build_relay_app(state: SharedState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/ready", get(ready)) + .route("/relay", get(relay_ws_handler)) + .with_state(state) +} + fn init_tracing(rust_log: &str) { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(rust_log)); let is_prod = env::var("OPENCADE_ENV") @@ -76,11 +108,10 @@ fn init_tracing(rust_log: &str) { } } -async fn health(State(state): State) -> impl IntoResponse { +async fn health() -> impl IntoResponse { let body = json!({ "status": "ok", "version": "0.1.0", - "relay_port": state.config.relay_port, }); (StatusCode::OK, Json(body)) } @@ -93,61 +124,66 @@ async fn ready() -> impl IntoResponse { async fn relay_ws_handler( ws: WebSocketUpgrade, State(state): State, - Query(params): Query>, -) -> impl IntoResponse { - let room_hint = params - .get("room_id") - .cloned() - .unwrap_or_else(|| "default".to_string()); - info!(room_hint = %room_hint, params = ?params, "relay_ws upgrade"); - ws.on_upgrade(move |socket| handle_socket(socket, state, room_hint)) -} - -fn extract_room_id_from_text(text: &str, fallback: &str) -> String { - if let Ok(value) = serde_json::from_str::(text) { - if let Some(payload) = value.get("payload") - && let Some(room) = payload.get("room_id").and_then(|v| v.as_str()) - && !room.trim().is_empty() - { - return room.to_string(); - } - if let Some(room) = value.get("room_id").and_then(|v| v.as_str()) - && !room.trim().is_empty() - { - return room.to_string(); + Query(ticket): Query, +) -> axum::response::Response { + let now = match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX), + Err(_) => { + return (StatusCode::SERVICE_UNAVAILABLE, "system clock unavailable").into_response(); } + }; + if ticket + .verify(state.config.auth_secret.as_bytes(), now) + .is_err() + { + return (StatusCode::UNAUTHORIZED, "invalid or expired relay ticket").into_response(); } - fallback.to_string() + let room_id = ticket.room_id; + let user_id = ticket.user_id; + info!(room_id = %room_id, "authorized relay upgrade"); + ws.on_upgrade(move |socket| handle_socket(socket, state, room_id, user_id)) + .into_response() } fn broadcast_message(state: &SharedState, room_id: &str, msg: Message, skip_id: Uuid) -> usize { let mut sent = 0usize; if let Some(room) = state.rooms.get(room_id) { for entry in room.iter() { - if *entry.key() == skip_id { + if entry.value().connection_id == skip_id { continue; } - let _ = entry.value().send(msg.clone()); - sent += 1; + if entry.value().sender.try_send(msg.clone()).is_ok() { + sent += 1; + } } } sent } -async fn handle_socket(socket: WebSocket, state: SharedState, initial_room: String) { +async fn handle_socket(socket: WebSocket, state: SharedState, room_id: String, user_id: String) { let id = Uuid::new_v4(); let (mut ws_sender, mut ws_receiver) = socket.split(); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::channel::(64); { - let room = state.rooms.entry(initial_room.clone()).or_default(); - room.insert(id, tx.clone()); + let room = state.rooms.entry(room_id.clone()).or_default(); + if room.len() >= 2 && !room.contains_key(&user_id) { + warn!(room_id = %room_id, "relay room is full"); + return; + } + room.insert( + user_id.clone(), + RelayPeer { + connection_id: id, + sender: tx.clone(), + }, + ); } - info!(peer_id = %id, room_id = %initial_room, "relay peer connected"); + info!(peer_id = %id, room_id = %room_id, "relay peer connected"); info!( peer_id = %id, - room_id = %initial_room, - peers_in_room = state.rooms.get(&initial_room).map(|r| r.len()).unwrap_or(0), + room_id = %room_id, + peers_in_room = state.rooms.get(&room_id).map(|r| r.len()).unwrap_or(0), "peer inserted" ); @@ -164,7 +200,10 @@ async fn handle_socket(socket: WebSocket, state: SharedState, initial_room: Stri Ok(msg) => match msg { Message::Text(text) => { let text_str = text.to_string(); - info!(peer_id = %id, room_id = %initial_room, text = %text_str, "recv text"); + if text_str.len() > 64 * 1024 { + warn!(peer_id = %id, room_id = %room_id, "oversized relay text rejected"); + continue; + } if let Ok(envelope) = serde_json::from_str::>(&text_str) { if !is_supported_version(&envelope.version) { warn!(peer_id = %id, version = %envelope.version, "unsupported version"); @@ -174,7 +213,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState, initial_room: Stri "message": format!("unsupported version: {}", envelope.version), "request_id": envelope.request_id, }); - let _ = tx.send(Message::Text(err.to_string().into())); + let _ = tx.try_send(Message::Text(err.to_string().into())); continue; } if let Err(e) = envelope.validate() { @@ -185,48 +224,36 @@ async fn handle_socket(socket: WebSocket, state: SharedState, initial_room: Stri "message": e, "request_id": envelope.request_id, }); - let _ = tx.send(Message::Text(err.to_string().into())); + let _ = tx.try_send(Message::Text(err.to_string().into())); continue; } - let target_room = envelope + let envelope_room = envelope .payload .get("room_id") .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| initial_room.clone()); - if target_room != initial_room { - let room = state.rooms.entry(target_room.clone()).or_default(); - room.insert(id, tx.clone()); + .unwrap_or_default(); + if envelope_room != room_id { + warn!(peer_id = %id, room_id = %room_id, "cross-room relay rejected"); + continue; } - let sent = broadcast_message( - &state, - &target_room, - Message::Text(text.clone()), - id, - ); - info!(peer_id = %id, target_room = %target_room, sent = sent, "broadcast envelope"); + let sent = + broadcast_message(&state, &room_id, Message::Text(text.clone()), id); + info!(peer_id = %id, room_id = %room_id, sent = sent, "broadcast envelope"); } else { - let target_room = extract_room_id_from_text(&text_str, &initial_room); - if target_room != initial_room { - let room = state.rooms.entry(target_room.clone()).or_default(); - room.insert(id, tx.clone()); - } - let sent = broadcast_message( - &state, - &target_room, - Message::Text(text.clone()), - id, - ); - info!(peer_id = %id, target_room = %target_room, sent = sent, "broadcast opaque"); + warn!(peer_id = %id, room_id = %room_id, "non-envelope relay text rejected"); } } Message::Binary(bin) => { + if bin.len() > 64 * 1024 { + warn!(peer_id = %id, room_id = %room_id, "oversized relay frame rejected"); + continue; + } let sent = - broadcast_message(&state, &initial_room, Message::Binary(bin.clone()), id); + broadcast_message(&state, &room_id, Message::Binary(bin.clone()), id); info!(peer_id = %id, sent = sent, "broadcast binary"); } Message::Ping(payload) => { - let _ = tx.send(Message::Pong(payload)); + let _ = tx.try_send(Message::Pong(payload)); } Message::Pong(_) => {} Message::Close(frame) => { @@ -243,8 +270,12 @@ async fn handle_socket(socket: WebSocket, state: SharedState, initial_room: Stri } } - for entry in state.rooms.iter() { - entry.remove(&id); + if let Some(room) = state.rooms.get(&room_id) + && room + .get(&user_id) + .is_some_and(|peer| peer.connection_id == id) + { + room.remove(&user_id); } let empty_rooms: Vec = state .rooms @@ -287,28 +318,17 @@ async fn shutdown_signal() { #[tokio::main] async fn main() -> Result<(), Box> { - let config = Config::from_env(); + let config = Config::from_env()?; init_tracing(&config.rust_log); - if let Some(host) = &config.relay_host { - info!(%host, relay_port = config.relay_port, "relay host configured"); - } - info!( - port = config.port, - relay_port = config.relay_port, - "opencade-relay starting" - ); + info!(port = config.port, "opencade-relay starting"); let state = Arc::new(RelayState { config: config.clone(), rooms: DashMap::new(), }); - let app = Router::new() - .route("/health", get(health)) - .route("/ready", get(ready)) - .route("/relay", get(relay_ws_handler)) - .with_state(state); + let app = build_relay_app(state); let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); let listener = tokio::net::TcpListener::bind(addr).await?; @@ -320,3 +340,76 @@ async fn main() -> Result<(), Box> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::{connect_async, tungstenite::Message as ClientMessage}; + + const SECRET: &str = "relay-integration-secret-at-least-32-bytes"; + + fn state() -> SharedState { + Arc::new(RelayState { + config: Config { + port: 0, + rust_log: "info".into(), + auth_secret: SECRET.into(), + }, + rooms: DashMap::new(), + }) + } + + fn ticket_url(address: SocketAddr, ticket: &RelayTicket) -> String { + format!( + "ws://{address}/relay?room_id={}&user_id={}&expires_at={}&signature={}", + ticket.room_id, ticket.user_id, ticket.expires_at, ticket.signature + ) + } + + #[tokio::test] + async fn signed_room_members_exchange_bounded_binary_frames() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("relay listener"); + let address = listener.local_addr().expect("relay address"); + let server = tokio::spawn(async move { + axum::serve(listener, build_relay_app(state())) + .await + .expect("relay server"); + }); + let now = i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_secs(), + ) + .expect("timestamp"); + let host = RelayTicket::issue(SECRET.as_bytes(), "room-1", "host", now + 120) + .expect("host ticket"); + let guest = RelayTicket::issue(SECRET.as_bytes(), "room-1", "guest", now + 120) + .expect("guest ticket"); + let (mut host_socket, _) = connect_async(ticket_url(address, &host)) + .await + .expect("host relay"); + let (mut guest_socket, _) = connect_async(ticket_url(address, &guest)) + .await + .expect("guest relay"); + + host_socket + .send(ClientMessage::Binary(b"bounded-frame".to_vec().into())) + .await + .expect("host send"); + let received = tokio::time::timeout(std::time::Duration::from_secs(1), guest_socket.next()) + .await + .expect("relay timeout") + .expect("relay message") + .expect("relay frame"); + assert_eq!(received.into_data().as_ref(), b"bounded-frame"); + + let mut tampered = host; + tampered.room_id = "other-room".into(); + assert!(connect_async(ticket_url(address, &tampered)).await.is_err()); + server.abort(); + } +}