Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
# OpenCade environment — copy to .env and adjust for your machine.
# Docker Compose reads this automatically when present.

DATABASE_URL=postgres://opencade:opencade@db:5432/opencade
POSTGRES_PASSWORD=replace-with-a-random-database-password
DATABASE_URL=postgres://opencade:replace-with-a-random-database-password@db:5432/opencade
# Generate with: openssl rand -hex 32
SESSION_SECRET=replace-with-at-least-32-random-bytes
# Generate independently with: openssl rand -hex 32
RELAY_AUTH_SECRET=replace-with-a-different-32-byte-random-secret
# Generate independently; protects metrics and aggregate decision endpoints.
OPERATOR_TOKEN=replace-with-a-third-32-byte-random-secret
ALLOWED_ORIGINS=http://localhost:1420,tauri://localhost,http://tauri.localhost,https://tauri.localhost
OPENCADE_ENV=production
PUBLIC_RELAY_URL=wss://relay.example.com/relay
STUN_HOST=stun.example.com

# Optional overrides:
# RUST_LOG=info
# PORT=8080
# POSTGRES_USER=opencade
# POSTGRES_PASSWORD=opencade
# 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
# RELAY_URL=wss://relay.example.com/relay
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,41 @@ jobs:
- name: Docker Compose configuration
run: docker compose config
env:
POSTGRES_PASSWORD: ci-postgres-password
SESSION_SECRET: ci-compose-secret-at-least-32-bytes
RELAY_AUTH_SECRET: ci-relay-secret-at-least-32-bytes-long
OPERATOR_TOKEN: ci-operator-token-at-least-32-bytes-long
PUBLIC_RELAY_URL: wss://relay.example.test/relay
STUN_HOST: stun.example.test

security:
name: Dependency and container security
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.98"
- name: Audit Rust dependencies
run: |
cargo install cargo-audit --locked
cargo audit --deny warnings
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Audit production JavaScript dependencies
run: pnpm audit --prod --audit-level high
- name: Build production containers
run: |
docker build -f apps/server/Dockerfile -t opencade-server:ci .
docker build -f services/relay/Dockerfile -t opencade-relay:ci .

desktop:
name: Desktop Rust (Windows / MSRV)
Expand Down
72 changes: 72 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Release

on:
push:
tags: ["v*"]

permissions:
contents: write
id-token: write
attestations: write

jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.98"
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: cargo build -p opencade-networking --bins --release --locked
- run: cargo build --manifest-path apps/client/src-tauri/Cargo.toml --release --locked
- name: Require and import Windows signing certificate
shell: pwsh
env:
WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
if (-not $env:WINDOWS_CERTIFICATE_BASE64 -or -not $env:WINDOWS_CERTIFICATE_PASSWORD) {
throw "Windows signing secrets are required for a release"
}
$certificatePath = Join-Path $env:RUNNER_TEMP "opencade-signing.pfx"
[IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64))
$certificate = Import-PfxCertificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
$env:OPENCADE_CERT_THUMBPRINT = $certificate.Thumbprint
"OPENCADE_CERT_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Sign and package alpha kit
shell: pwsh
run: |
$signTool = (Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Filter signtool.exe -Recurse | Sort-Object FullName -Descending | Select-Object -First 1).FullName
if (-not $signTool) { throw "signtool.exe was not found" }
$executables = @(
"apps\client\src-tauri\target\release\opencade-client.exe",
"target\release\opencade-match-probe.exe",
"target\release\opencade-match-verify.exe",
"target\release\opencade-alpha-summary.exe"
)
foreach ($executable in $executables) {
& $signTool sign /sha1 $env:OPENCADE_CERT_THUMBPRINT /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 $executable
if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed: $executable" }
& $signTool verify /pa /all $executable
if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed: $executable" }
}
.\scripts\alpha\OpenCade-Alpha.ps1 -Mode Package -KitRoot release-kit
Compress-Archive -Path release-kit\* -DestinationPath "opencade-$env:GITHUB_REF_NAME-windows-x64.zip"
(Get-FileHash "opencade-$env:GITHUB_REF_NAME-windows-x64.zip" -Algorithm SHA256).Hash.ToLower() + " opencade-$env:GITHUB_REF_NAME-windows-x64.zip" | Out-File "SHA256SUMS.txt" -Encoding ascii
- uses: actions/attest-build-provenance@v2
with:
subject-path: opencade-*-windows-x64.zip
- name: Publish immutable GitHub release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api "repos/$GITHUB_REPOSITORY/dependency-graph/sbom" > sbom.spdx.json
gh release create "$GITHUB_REF_NAME" opencade-*-windows-x64.zip SHA256SUMS.txt sbom.spdx.json --verify-tag --generate-notes
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ curl http://localhost:8080/health
docker compose logs -f opencade-server
```

This starts `opencade-server`, PostgreSQL, and the authenticated readiness-probe relay. The server applies committed SQLx migrations before it begins serving.
This starts PostgreSQL, runs committed SQLx migrations as a one-shot job, then starts `opencade-server` and the authenticated readiness-probe relay. See [the production runbook](docs/operations/RUNBOOK.md) for backup, restore, deployment, and rollback procedures.

```bash
# stop
Expand Down
1 change: 1 addition & 0 deletions apps/client/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 62 additions & 6 deletions apps/client/src-tauri/src/commands/match_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ use tokio::sync::{Mutex, watch};
use uuid::Uuid;

const MAX_ACTIVE_PROBES: usize = 8;
const MIN_PROBE_TIMEOUT_MS: u64 = 500;
const MAX_PROBE_TIMEOUT_MS: u64 = 15_000;
const MAX_PROBE_FRAMES: u64 = 600;

#[derive(Default)]
pub struct MatchProbeState {
Expand Down Expand Up @@ -166,6 +169,7 @@ pub async fn run_reserved_match_probe(
request: RunMatchProbeRequest,
) -> Result<MatchProbeReport, String> {
validate_room_id(&request.room_id)?;
validate_probe_limits(request.frame_count, request.timeout_ms)?;
let room_id = request.room_id.clone();
let run_id = Uuid::new_v4();
let (cancel_tx, cancel_rx) = watch::channel(false);
Expand Down Expand Up @@ -251,14 +255,35 @@ pub async fn run_reserved_match_probe(

#[tauri::command]
pub async fn run_relay_match_probe_command(
state: tauri::State<'_, MatchProbeState>,
request: RunRelayMatchProbeRequest,
) -> Result<MatchProbeReport, String> {
validate_room_id(&request.room_id)?;
validate_probe_limits(request.frame_count, request.timeout_ms)?;
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 room_id = request.room_id.clone();
let run_id = Uuid::new_v4();
let (cancel_tx, cancel_rx) = watch::channel(false);
{
let mut probes = state.probes.lock().await;
if probes.contains_key(&room_id) {
return Err("match probe is already active for this room".into());
}
if probes.len() >= MAX_ACTIVE_PROBES {
return Err("too many active match probes; cancel an older room first".into());
}
probes.insert(
room_id.clone(),
MatchProbeSlot::Running {
run_id,
cancel: cancel_tx,
},
);
}
let descriptor = MatchDescriptor {
room_id: request.room_id,
game_id: request.game_id,
Expand All @@ -277,12 +302,17 @@ pub async fn run_relay_match_probe_command(
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())?;
let probe = async {
let peer = RelayPeer::connect(&request.relay_url, &request.ticket)
.await
.map_err(|error| error.to_string())?;
run_relay_match_probe(&peer, &config)
.await
.map_err(|error| error.to_string())
};
let result = cancel_or_complete(probe, cancel_rx).await;
remove_running_probe(&state, &room_id, run_id).await;
let report = result?;
tracing::info!(
room_id = %report.room_id,
frames = report.frames_received,
Expand All @@ -292,6 +322,32 @@ pub async fn run_relay_match_probe_command(
Ok(report)
}

fn validate_probe_limits(frame_count: u64, timeout_ms: u64) -> Result<(), String> {
if !(1..=MAX_PROBE_FRAMES).contains(&frame_count) {
return Err(format!(
"frame_count must be between 1 and {MAX_PROBE_FRAMES}"
));
}
if !(MIN_PROBE_TIMEOUT_MS..=MAX_PROBE_TIMEOUT_MS).contains(&timeout_ms) {
return Err(format!(
"timeout_ms must be between {MIN_PROBE_TIMEOUT_MS} and {MAX_PROBE_TIMEOUT_MS}"
));
}
Ok(())
}

impl MatchProbeState {
pub fn shutdown_all(&self) {
if let Ok(mut probes) = self.probes.try_lock() {
for (_, slot) in probes.drain() {
if let MatchProbeSlot::Running { cancel, .. } = slot {
let _ = cancel.send(true);
}
}
}
}
}

#[tauri::command]
pub async fn cancel_match_probe(
state: tauri::State<'_, MatchProbeState>,
Expand Down
1 change: 1 addition & 0 deletions apps/client/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pub mod match_probe;
pub mod process;
pub mod runtime;
pub mod session;
pub mod tunnel;
Loading
Loading