Replace the Python/kaya server in server/ with a Rust Cargo workspace of three crates mirroring the old layout: tavolo-platform (lobby API, live-game store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions), tavolo-scopone (pure scopone scientifico rules plus the platform adapter) and tavolo-app (composition root and the `tavolo` binary with serve/migrate/backfill-elo). The HTTP and WebSocket contracts, error messages, Redis key layout and Postgres schema are unchanged, so the existing web/ frontend and in-flight games keep working. Notable differences: sessions are JSON under tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations replace aerich, and OpenTelemetry exports Rust process metrics instead of CPython gc metrics (no Postgres/Redis client instrumentation). Docker Compose and the Kubernetes manifest now run the single Rust image (embedded migrations, /static and /assets served by the app); the release CI keeps building server/Dockerfile. The full test suite (scopone rules, platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is ported and runs with cargo test --workspace; Redis/Postgres integration tests are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
This commit is contained in:
1 parent
adc3d340b1
commit
7b3b38ccbc
136 files changed
+16821
-11777
No files matched your search
@@ -0,0 +1,63 @@
|
||||
[package]
|
||||
name = "tavolo-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "The deployable tavolo application: composition root and HTTP/WebSocket server"
|
||||
|
||||
[[bin]]
|
||||
name = "tavolo"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
futures-util.workspace = true
|
||||
hex.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
opentelemetry = { version = "0.33", optional = true, default-features = false, features = ["trace", "metrics", "logs"] }
|
||||
opentelemetry-appender-tracing = { version = "0.33", optional = true, default-features = false }
|
||||
opentelemetry-otlp = { version = "0.33", optional = true, default-features = false, features = ["http-proto", "reqwest-client", "trace", "metrics", "logs"] }
|
||||
opentelemetry_sdk = { version = "0.33", optional = true, default-features = false, features = ["trace", "metrics", "logs", "rt-tokio"] }
|
||||
percent-encoding.workspace = true
|
||||
rand.workspace = true
|
||||
redis.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
sysinfo = { version = "0.39", optional = true }
|
||||
tavolo-platform = { path = "../tavolo-platform" }
|
||||
tavolo-scopone = { path = "../tavolo-scopone" }
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-opentelemetry = { version = "0.34", optional = true, default-features = false }
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
axum = { workspace = true, features = ["ws", "macros"] }
|
||||
futures-util.workspace = true
|
||||
http-body-util = "0.1"
|
||||
tokio-tungstenite = "0.24"
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
|
||||
[features]
|
||||
default = ["otel"]
|
||||
otel = [
|
||||
"dep:opentelemetry",
|
||||
"dep:opentelemetry_sdk",
|
||||
"dep:opentelemetry-otlp",
|
||||
"dep:opentelemetry-appender-tracing",
|
||||
"dep:tracing-opentelemetry",
|
||||
"dep:sysinfo",
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Final tavolo schema, expressed idempotently so the migration can be applied
|
||||
-- to a database created by the Python aerich migrations as well as to a fresh
|
||||
-- one. The `aerich` bookkeeping table (when present) is left untouched.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "match" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"game_type" VARCHAR(32) NOT NULL,
|
||||
"started_at" TIMESTAMPTZ NOT NULL,
|
||||
"finished_at" TIMESTAMPTZ NOT NULL,
|
||||
"result" JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_match_game_type" ON "match" ("game_type");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "match_player" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"user_sub" VARCHAR(255) NOT NULL,
|
||||
"display_name" VARCHAR(200) NOT NULL,
|
||||
"seat" SMALLINT NOT NULL,
|
||||
"team" VARCHAR(32),
|
||||
"won" BOOL NOT NULL,
|
||||
"match_id" UUID NOT NULL REFERENCES "match" ("id") ON DELETE CASCADE,
|
||||
"score" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"elo_delta" SMALLINT,
|
||||
"details" JSONB NOT NULL DEFAULT '{}',
|
||||
CONSTRAINT "uid_match_player_match_user" UNIQUE ("match_id", "user_sub")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_match_player_user_sub" ON "match_player" ("user_sub");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "player_rating" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"user_sub" VARCHAR(255) NOT NULL,
|
||||
"game_type" VARCHAR(32) NOT NULL,
|
||||
"rating" INT NOT NULL,
|
||||
"matches_played" INT NOT NULL,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
CONSTRAINT "uid_player_rating_user_game" UNIQUE ("user_sub", "game_type")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_player_rating_game_rating" ON "player_rating" ("game_type", "rating");
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Application composition root: registry, stores, platform, router.
|
||||
//!
|
||||
//! Mirrors `tavolo.app` from the Python implementation:
|
||||
//!
|
||||
//! * the [`ScoponeEngine`] is registered with the configured timeouts;
|
||||
//! * sessions and live games use Redis when `REDIS_URL` is set, in-memory
|
||||
//! stores otherwise;
|
||||
//! * finished matches are persisted to Postgres through
|
||||
//! [`PostgresStatsRepo`];
|
||||
//! * the platform routes are merged with the SPA shell hosting and wrapped in
|
||||
//! the session middleware and the optional CORS layer.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use tavolo_platform::deadlines::DeadlineScheduler;
|
||||
use tavolo_platform::oidc::OidcProviders;
|
||||
use tavolo_platform::platform::{Platform, SharedPlatform};
|
||||
use tavolo_platform::registry::GameRegistry;
|
||||
use tavolo_platform::routes;
|
||||
use tavolo_platform::session::{
|
||||
session_middleware, InMemorySessionStore, RedisSessionStore, SharedSessionStore,
|
||||
};
|
||||
use tavolo_platform::stats::{InMemoryStatsRepo, PostgresStatsRepo, SharedStatsRepo};
|
||||
use tavolo_platform::store::{InMemoryGameStore, RedisGameStore, SharedGameStore};
|
||||
use tavolo_scopone::ScoponeEngine;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::config::Settings;
|
||||
use crate::cors::CorsConfig;
|
||||
use crate::static_files::{mount_dirs, SpaFallback};
|
||||
|
||||
pub struct App {
|
||||
pub router: Router,
|
||||
pub platform: SharedPlatform,
|
||||
pub session_store: SharedSessionStore,
|
||||
}
|
||||
|
||||
/// Build the game registry (the first registered game is the default).
|
||||
pub fn build_registry(settings: &Settings) -> Result<Arc<GameRegistry>, String> {
|
||||
let mut registry = GameRegistry::new();
|
||||
registry
|
||||
.register(Arc::new(ScoponeEngine::new(
|
||||
settings.turn_timeout_seconds,
|
||||
settings.hand_ack_timeout_seconds,
|
||||
)))
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(Arc::new(registry))
|
||||
}
|
||||
|
||||
/// Build the session and live-game stores from `REDIS_URL`.
|
||||
pub async fn build_stores(
|
||||
settings: &Settings,
|
||||
registry: Arc<GameRegistry>,
|
||||
) -> Result<(SharedSessionStore, SharedGameStore), String> {
|
||||
if let Some(url) = &settings.redis_url {
|
||||
let session_store: SharedSessionStore = Arc::new(
|
||||
RedisSessionStore::new(url)
|
||||
.await
|
||||
.map_err(|err| format!("could not connect to Redis: {err}"))?,
|
||||
);
|
||||
let game_store: SharedGameStore = Arc::new(
|
||||
RedisGameStore::new(url, registry, settings.game_ttl_seconds)
|
||||
.await
|
||||
.map_err(|err| format!("could not connect to Redis: {err}"))?,
|
||||
);
|
||||
Ok((session_store, game_store))
|
||||
} else {
|
||||
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
|
||||
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry));
|
||||
Ok((session_store, game_store))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the [`Platform`] bundle and start the deadline scheduler.
|
||||
pub fn build_platform(
|
||||
settings: &Settings,
|
||||
registry: Arc<GameRegistry>,
|
||||
game_store: SharedGameStore,
|
||||
stats: SharedStatsRepo,
|
||||
) -> Result<SharedPlatform, String> {
|
||||
let oidc = Arc::new(
|
||||
OidcProviders::new(settings.oidc_providers.clone()).map_err(|err| err.to_string())?,
|
||||
);
|
||||
let scheduler = DeadlineScheduler::new(
|
||||
game_store.clone(),
|
||||
registry.clone(),
|
||||
stats.clone(),
|
||||
settings.deadline_heartbeat_ms,
|
||||
1000,
|
||||
);
|
||||
scheduler.start();
|
||||
Ok(Arc::new(Platform {
|
||||
registry,
|
||||
game_store,
|
||||
scheduler,
|
||||
oidc,
|
||||
stats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build the axum router: platform routes, static mounts, SPA fallback,
|
||||
/// session middleware and optional CORS.
|
||||
pub fn build_router(
|
||||
platform: SharedPlatform,
|
||||
session_store: SharedSessionStore,
|
||||
settings: &Settings,
|
||||
) -> Router {
|
||||
let (root, assets) = mount_dirs(&settings.static_dir);
|
||||
let provider_names: Vec<String> = settings
|
||||
.oidc_providers
|
||||
.iter()
|
||||
.map(|provider| provider.provider.clone())
|
||||
.collect();
|
||||
let mut router = Router::new()
|
||||
.merge(routes::router(&provider_names).with_state(platform))
|
||||
.nest_service("/static", ServeDir::new(root))
|
||||
.nest_service("/assets", ServeDir::new(assets))
|
||||
.fallback_service(SpaFallback::new(&settings.static_dir));
|
||||
// Added before the session layer, so request spans see the loaded
|
||||
// session while the session middleware stays the outer layer.
|
||||
let excluded = Arc::new(settings.otel_excluded_paths.clone());
|
||||
router = router.layer(axum::middleware::from_fn(move |request, next| {
|
||||
let excluded = Arc::clone(&excluded);
|
||||
crate::telemetry::trace_middleware(excluded, request, next)
|
||||
}));
|
||||
router = router.layer(axum::middleware::from_fn_with_state(
|
||||
session_store,
|
||||
session_middleware,
|
||||
));
|
||||
if let Some(config) = CorsConfig::new(
|
||||
settings.cors_allow_origins.as_deref().unwrap_or(&[]),
|
||||
settings.cors_allow_methods.as_deref(),
|
||||
settings.cors_allow_headers.as_deref(),
|
||||
settings.cors_allow_credentials,
|
||||
settings.cors_allow_origin_regex.as_deref(),
|
||||
settings.cors_expose_headers.as_deref().unwrap_or(&[]),
|
||||
settings.cors_max_age,
|
||||
) {
|
||||
router = router.layer(axum::middleware::from_fn_with_state(
|
||||
Arc::new(config),
|
||||
crate::cors::cors_middleware,
|
||||
));
|
||||
}
|
||||
router
|
||||
}
|
||||
|
||||
/// Build everything in-memory: useful for tests and ephemeral dev without
|
||||
/// Postgres or Redis.
|
||||
pub fn in_memory_platform(
|
||||
settings: &Settings,
|
||||
) -> Result<(SharedPlatform, SharedSessionStore), String> {
|
||||
let registry = build_registry(settings)?;
|
||||
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
|
||||
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
|
||||
let stats: SharedStatsRepo = Arc::new(InMemoryStatsRepo::new());
|
||||
let platform = build_platform(settings, registry, game_store, stats)?;
|
||||
Ok((platform, session_store))
|
||||
}
|
||||
|
||||
/// Connect to Postgres.
|
||||
pub async fn connect_database(settings: &Settings) -> Result<sqlx::PgPool, String> {
|
||||
if settings.database_url.starts_with("sqlite") {
|
||||
return Err(
|
||||
"the Rust server supports Postgres only; sqlite DATABASE_URLs are not supported"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.connect(&settings.database_url)
|
||||
.await
|
||||
.map_err(|err| format!("could not connect to the database: {err}"))
|
||||
}
|
||||
|
||||
/// Build the production application (requires Postgres; Redis optional).
|
||||
pub async fn build(settings: &Settings) -> Result<App, String> {
|
||||
let registry = build_registry(settings)?;
|
||||
let (session_store, game_store) = build_stores(settings, registry.clone()).await?;
|
||||
let pool = connect_database(settings).await?;
|
||||
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
|
||||
let platform = build_platform(settings, registry, game_store, stats)?;
|
||||
let router = build_router(platform.clone(), session_store.clone(), settings);
|
||||
Ok(App {
|
||||
router,
|
||||
platform,
|
||||
session_store,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Environment-driven configuration for the tavolo application.
|
||||
//!
|
||||
//! Mirrors `tavolo.config` from the Python implementation: read environment
|
||||
//! variables into a plain struct, with the same defaults and validation.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use tavolo_platform::oidc::OidcProviderConfig;
|
||||
|
||||
const OIDC_VARIABLES: [&str; 7] = [
|
||||
"PROVIDER",
|
||||
"ISSUER",
|
||||
"CLIENT_ID",
|
||||
"CLIENT_SECRET",
|
||||
"REDIRECT_URI",
|
||||
"POST_LOGIN_REDIRECT",
|
||||
"POST_LOGOUT_REDIRECT",
|
||||
];
|
||||
|
||||
/// The pre-0.0.6 unindexed names, detected only to point the migration out.
|
||||
const LEGACY_OIDC_VARIABLES: [&str; 6] = [
|
||||
"OIDC_ISSUER",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_REDIRECT_URI",
|
||||
"OIDC_POST_LOGIN_REDIRECT",
|
||||
"OIDC_POST_LOGOUT_REDIRECT",
|
||||
];
|
||||
|
||||
const DEFAULT_OIDC_ISSUER: &str = "http://localhost:8180/tavolo";
|
||||
const DEFAULT_OIDC_CLIENT_ID: &str = "tavolo";
|
||||
|
||||
fn env(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn env_or(name: &str, default: &str) -> String {
|
||||
env(name).unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
fn env_list(name: &str) -> Option<Vec<String>> {
|
||||
let value = std::env::var(name).ok()?;
|
||||
if value.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn env_bool(name: &str, default: bool) -> bool {
|
||||
match std::env::var(name) {
|
||||
Ok(value) if !value.is_empty() => {
|
||||
matches!(
|
||||
value.trim().to_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_oidc_providers() -> Result<Vec<OidcProviderConfig>, String> {
|
||||
let legacy: Vec<&str> = LEGACY_OIDC_VARIABLES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|name| env(name).is_some())
|
||||
.collect();
|
||||
if !legacy.is_empty() {
|
||||
let renamed: Vec<String> = legacy
|
||||
.iter()
|
||||
.map(|name| format!("{name} -> OIDC_0_{}", &name["OIDC_".len()..]))
|
||||
.collect();
|
||||
return Err(format!(
|
||||
"the OIDC variables are now indexed per provider ({}; add OIDC_0_PROVIDER)",
|
||||
renamed.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let pattern = Regex::new(r"^OIDC_(\d+)_([A-Z0-9_]+)$").expect("valid regex");
|
||||
let mut indices: BTreeSet<usize> = BTreeSet::new();
|
||||
for (name, _) in std::env::vars() {
|
||||
let Some(captures) = pattern.captures(&name) else {
|
||||
continue;
|
||||
};
|
||||
let variable = captures.get(2).expect("group 2").as_str();
|
||||
if !OIDC_VARIABLES.contains(&variable) {
|
||||
return Err(format!(
|
||||
"unknown OIDC environment variable {name:?}; expected one of {}",
|
||||
OIDC_VARIABLES
|
||||
.iter()
|
||||
.map(|variable| format!("OIDC_<n>_{variable}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
indices.insert(
|
||||
captures
|
||||
.get(1)
|
||||
.expect("group 1")
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid OIDC provider index in {name:?}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
if indices.is_empty() {
|
||||
return Ok(vec![OidcProviderConfig {
|
||||
provider: "default".into(),
|
||||
issuer: DEFAULT_OIDC_ISSUER.into(),
|
||||
client_id: DEFAULT_OIDC_CLIENT_ID.into(),
|
||||
client_secret: None,
|
||||
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
|
||||
post_login_redirect: "/".into(),
|
||||
post_logout_redirect: "/".into(),
|
||||
}]);
|
||||
}
|
||||
|
||||
let max = *indices.iter().next_back().expect("non-empty");
|
||||
let missing: Vec<usize> = (0..=max).filter(|index| !indices.contains(index)).collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"OIDC provider indices must be contiguous from 0; missing {}",
|
||||
missing
|
||||
.iter()
|
||||
.map(|index| format!("OIDC_{index}_*"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let provider_name = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid regex");
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut providers = Vec::new();
|
||||
for index in indices {
|
||||
let prefix = format!("OIDC_{index}_");
|
||||
let required = |variable: &str| -> Result<String, String> {
|
||||
env(&format!("{prefix}{variable}"))
|
||||
.ok_or_else(|| format!("Missing required environment variable: {prefix}{variable}"))
|
||||
};
|
||||
let provider = required("PROVIDER")?;
|
||||
if !provider_name.is_match(&provider) {
|
||||
return Err(format!(
|
||||
"{prefix}PROVIDER must be a non-empty string of letters, digits, underscores or hyphens (got {provider:?})"
|
||||
));
|
||||
}
|
||||
if !seen.insert(provider.clone()) {
|
||||
return Err(format!("duplicate OIDC provider name {provider:?}"));
|
||||
}
|
||||
providers.push(OidcProviderConfig {
|
||||
issuer: required("ISSUER")?,
|
||||
client_id: required("CLIENT_ID")?,
|
||||
client_secret: env(&format!("{prefix}CLIENT_SECRET")),
|
||||
redirect_uri: env_or(
|
||||
&format!("{prefix}REDIRECT_URI"),
|
||||
&format!("http://localhost:8080/auth/{provider}/callback"),
|
||||
),
|
||||
post_login_redirect: env_or(&format!("{prefix}POST_LOGIN_REDIRECT"), "/"),
|
||||
post_logout_redirect: env_or(&format!("{prefix}POST_LOGOUT_REDIRECT"), "/"),
|
||||
provider,
|
||||
});
|
||||
}
|
||||
Ok(providers)
|
||||
}
|
||||
|
||||
/// Percent-encode a DSN component like Python's `quote(value, safe="")`,
|
||||
/// which leaves the RFC 3986 unreserved characters alone.
|
||||
fn quote(value: &str) -> String {
|
||||
const QUOTE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
|
||||
.remove(b'_')
|
||||
.remove(b'-')
|
||||
.remove(b'.')
|
||||
.remove(b'~');
|
||||
percent_encoding::utf8_percent_encode(value, QUOTE_SET).to_string()
|
||||
}
|
||||
|
||||
fn database_url_from_parts(
|
||||
engine: &str,
|
||||
user: &str,
|
||||
password: Option<&str>,
|
||||
host: &str,
|
||||
port: &str,
|
||||
name: &str,
|
||||
options: &str,
|
||||
) -> String {
|
||||
let mut netloc = quote(user);
|
||||
if let Some(password) = password {
|
||||
if !password.is_empty() {
|
||||
netloc.push(':');
|
||||
netloc.push_str("e(password));
|
||||
}
|
||||
}
|
||||
netloc.push('@');
|
||||
netloc.push_str(host);
|
||||
if !port.is_empty() {
|
||||
netloc.push(':');
|
||||
netloc.push_str(port);
|
||||
}
|
||||
let mut url = format!("{engine}://{netloc}/{name}");
|
||||
let options = options.trim_start_matches('?');
|
||||
if !options.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(options);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Settings {
|
||||
pub database_url: String,
|
||||
pub oidc_providers: Vec<OidcProviderConfig>,
|
||||
pub app_host: String,
|
||||
pub app_port: u16,
|
||||
pub redis_url: Option<String>,
|
||||
pub game_ttl_seconds: i64,
|
||||
pub static_dir: String,
|
||||
pub hand_ack_timeout_seconds: i64,
|
||||
pub turn_timeout_seconds: i64,
|
||||
pub deadline_heartbeat_ms: u64,
|
||||
/// Accepted for compatibility; Rust logging is configured via RUST_LOG.
|
||||
pub logging_config: Option<String>,
|
||||
pub cors_allow_origins: Option<Vec<String>>,
|
||||
pub cors_allow_origin_regex: Option<String>,
|
||||
pub cors_allow_methods: Option<Vec<String>>,
|
||||
pub cors_allow_headers: Option<Vec<String>>,
|
||||
pub cors_allow_credentials: bool,
|
||||
pub cors_expose_headers: Option<Vec<String>>,
|
||||
pub cors_max_age: i64,
|
||||
pub otel_enabled: bool,
|
||||
pub otel_service_name: String,
|
||||
pub otel_exporter_endpoint: Option<String>,
|
||||
pub otel_exporter_headers: Option<Vec<String>>,
|
||||
pub otel_excluded_paths: Vec<String>,
|
||||
pub otel_metric_export_interval_millis: u64,
|
||||
pub otel_metrics_poll_interval_seconds: u64,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
let database_url = env("DATABASE_URL").unwrap_or_else(|| {
|
||||
database_url_from_parts(
|
||||
&env_or("DATABASE_ENGINE", "postgres"),
|
||||
&env_or("DATABASE_USER", "tavolo"),
|
||||
Some(&env_or("DATABASE_PASSWORD", "password")),
|
||||
&env_or("DATABASE_HOST", "localhost"),
|
||||
&std::env::var("DATABASE_PORT").unwrap_or_default(),
|
||||
&env_or("DATABASE_NAME", "tavolo"),
|
||||
&std::env::var("DATABASE_OPTIONS").unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
let app_port = env_or("APP_PORT", "8080")
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "APP_PORT must be a valid port number".to_string())?;
|
||||
let otel_headers = env_list("OTEL_EXPORTER_OTLP_HEADERS");
|
||||
Ok(Self {
|
||||
database_url,
|
||||
oidc_providers: parse_oidc_providers()?,
|
||||
app_host: env_or("APP_HOST", "0.0.0.0"),
|
||||
app_port,
|
||||
redis_url: env("REDIS_URL"),
|
||||
game_ttl_seconds: env_or("GAME_TTL_SECONDS", "86400")
|
||||
.parse()
|
||||
.map_err(|_| "GAME_TTL_SECONDS must be an integer".to_string())?,
|
||||
static_dir: env_or("STATIC_DIR", "web/dist"),
|
||||
hand_ack_timeout_seconds: env_or("HAND_ACK_TIMEOUT_SECONDS", "30")
|
||||
.parse()
|
||||
.map_err(|_| "HAND_ACK_TIMEOUT_SECONDS must be an integer".to_string())?,
|
||||
turn_timeout_seconds: env_or("TURN_TIMEOUT_SECONDS", "30")
|
||||
.parse()
|
||||
.map_err(|_| "TURN_TIMEOUT_SECONDS must be an integer".to_string())?,
|
||||
deadline_heartbeat_ms: env_or("DEADLINE_HEARTBEAT_MS", "30000")
|
||||
.parse()
|
||||
.map_err(|_| "DEADLINE_HEARTBEAT_MS must be an integer".to_string())?,
|
||||
logging_config: env("LOGGING_CONFIG"),
|
||||
cors_allow_origins: env_list("CORS_ALLOW_ORIGINS"),
|
||||
cors_allow_origin_regex: env("CORS_ALLOW_ORIGIN_REGEX"),
|
||||
cors_allow_methods: env_list("CORS_ALLOW_METHODS"),
|
||||
cors_allow_headers: env_list("CORS_ALLOW_HEADERS"),
|
||||
cors_allow_credentials: env_bool("CORS_ALLOW_CREDENTIALS", false),
|
||||
cors_expose_headers: env_list("CORS_EXPOSE_HEADERS"),
|
||||
cors_max_age: env_or("CORS_MAX_AGE", "600")
|
||||
.parse()
|
||||
.map_err(|_| "CORS_MAX_AGE must be an integer".to_string())?,
|
||||
otel_enabled: env_bool("OTEL_ENABLED", false),
|
||||
otel_service_name: env_or("OTEL_SERVICE_NAME", "tavolo"),
|
||||
otel_exporter_endpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT"),
|
||||
otel_exporter_headers: otel_headers,
|
||||
otel_excluded_paths: env_list("OTEL_EXCLUDED_PATHS")
|
||||
.unwrap_or_else(|| vec!["/api/health".to_string()]),
|
||||
otel_metric_export_interval_millis: env_or(
|
||||
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
|
||||
"60000",
|
||||
)
|
||||
.parse()
|
||||
.map_err(|_| "OTEL_METRIC_EXPORT_INTERVAL_MILLIS must be an integer".to_string())?,
|
||||
otel_metrics_poll_interval_seconds: env_or("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
|
||||
.parse()
|
||||
.map_err(|_| "OTEL_METRICS_POLL_INTERVAL_SECONDS must be an integer".to_string())?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! CORS middleware replicating kaya-cors' `CorsMixin` (modeled after
|
||||
//! Starlette's `CORSMiddleware`).
|
||||
//!
|
||||
//! * requests without an `Origin` header pass through untouched;
|
||||
//! * preflight requests (`OPTIONS` carrying `Origin` and
|
||||
//! `Access-Control-Request-Method`) are answered directly: `200 OK` when
|
||||
//! the origin, method and headers are allowed, `400` with a
|
||||
//! `Disallowed CORS ...` body otherwise;
|
||||
//! * simple cross-origin requests get `Access-Control-Allow-Origin` (and the
|
||||
//! configured credentials/expose headers) merged into the response without
|
||||
//! overwriting headers the handler already set.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use regex::Regex;
|
||||
|
||||
const ALL_METHODS: [&str; 8] = [
|
||||
"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY",
|
||||
];
|
||||
const SAFELISTED_HEADERS: [&str; 4] = [
|
||||
"Accept",
|
||||
"Accept-Language",
|
||||
"Content-Language",
|
||||
"Content-Type",
|
||||
];
|
||||
|
||||
pub struct CorsConfig {
|
||||
allow_all_origins: bool,
|
||||
allow_credentials: bool,
|
||||
allow_origins: Vec<String>,
|
||||
allow_origin_regex: Option<Regex>,
|
||||
allow_methods: Vec<String>,
|
||||
allow_all_headers: bool,
|
||||
/// Lowercased set checked against requested headers.
|
||||
allow_headers_lower: HashSet<String>,
|
||||
preflight_explicit_allow_origin: bool,
|
||||
simple_headers: Vec<(HeaderName, HeaderValue)>,
|
||||
preflight_headers: Vec<(HeaderName, HeaderValue)>,
|
||||
}
|
||||
|
||||
impl CorsConfig {
|
||||
pub fn new(
|
||||
allow_origins: &[String],
|
||||
allow_methods: Option<&[String]>,
|
||||
allow_headers: Option<&[String]>,
|
||||
allow_credentials: bool,
|
||||
allow_origin_regex: Option<&str>,
|
||||
expose_headers: &[String],
|
||||
max_age: i64,
|
||||
) -> Option<Self> {
|
||||
if allow_origins.is_empty() && allow_origin_regex.is_none() {
|
||||
return None;
|
||||
}
|
||||
let allow_all_origins = allow_origins.iter().any(|origin| origin == "*");
|
||||
let allow_all_headers = allow_headers
|
||||
.map(|headers| headers.iter().any(|header| header == "*"))
|
||||
.unwrap_or(false);
|
||||
let methods: Vec<String> = match allow_methods {
|
||||
Some(methods) if methods.iter().any(|method| method == "*") => ALL_METHODS
|
||||
.iter()
|
||||
.map(|method| method.to_string())
|
||||
.collect(),
|
||||
Some(methods) => methods.to_vec(),
|
||||
None => vec!["GET".to_string()],
|
||||
};
|
||||
|
||||
let configured = allow_headers.unwrap_or_default();
|
||||
let mut sorted_allow_headers: Vec<String> = SAFELISTED_HEADERS
|
||||
.iter()
|
||||
.map(|header| header.to_string())
|
||||
.chain(configured.iter().cloned())
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
sorted_allow_headers.sort();
|
||||
let allow_headers_lower: HashSet<String> = sorted_allow_headers
|
||||
.iter()
|
||||
.map(|header| header.to_lowercase())
|
||||
.collect();
|
||||
|
||||
let preflight_explicit_allow_origin = !allow_all_origins || allow_credentials;
|
||||
let simple_headers =
|
||||
build_simple_headers(allow_all_origins, allow_credentials, expose_headers);
|
||||
let preflight_headers = build_preflight_headers(
|
||||
&methods,
|
||||
&sorted_allow_headers,
|
||||
allow_all_headers,
|
||||
allow_credentials,
|
||||
max_age,
|
||||
preflight_explicit_allow_origin,
|
||||
);
|
||||
|
||||
Some(Self {
|
||||
allow_all_origins,
|
||||
allow_credentials,
|
||||
allow_origins: allow_origins.to_vec(),
|
||||
allow_origin_regex: allow_origin_regex.and_then(|pattern| Regex::new(pattern).ok()),
|
||||
allow_methods: methods,
|
||||
allow_all_headers,
|
||||
allow_headers_lower,
|
||||
preflight_explicit_allow_origin,
|
||||
simple_headers,
|
||||
preflight_headers,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_allowed_origin(&self, origin: &str) -> bool {
|
||||
if self.allow_all_origins {
|
||||
return true;
|
||||
}
|
||||
if let Some(regex) = &self.allow_origin_regex {
|
||||
if regex.is_match(origin) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.allow_origins.iter().any(|allowed| allowed == origin)
|
||||
}
|
||||
|
||||
fn simple_response_headers(&self, origin: &str) -> Vec<(HeaderName, HeaderValue)> {
|
||||
let mut headers = self.simple_headers.clone();
|
||||
if self.allow_all_origins && self.allow_credentials {
|
||||
// Credentials require the specific origin instead of '*'.
|
||||
upsert(
|
||||
&mut headers,
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
|
||||
);
|
||||
upsert(
|
||||
&mut headers,
|
||||
header::VARY,
|
||||
HeaderValue::from_static("Origin"),
|
||||
);
|
||||
} else if !self.allow_all_origins && self.is_allowed_origin(origin) {
|
||||
// Specific origins are mirrored back in the response.
|
||||
upsert(
|
||||
&mut headers,
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
|
||||
);
|
||||
upsert(
|
||||
&mut headers,
|
||||
header::VARY,
|
||||
HeaderValue::from_static("Origin"),
|
||||
);
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn preflight_response(&self, request_headers: &HeaderMap, origin: &str) -> Response {
|
||||
let mut headers = self.preflight_headers.clone();
|
||||
let mut failures: Vec<&'static str> = Vec::new();
|
||||
|
||||
if self.is_allowed_origin(origin) {
|
||||
if self.preflight_explicit_allow_origin {
|
||||
if let Ok(value) = HeaderValue::from_str(origin) {
|
||||
headers.push((header::ACCESS_CONTROL_ALLOW_ORIGIN, value));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
failures.push("origin");
|
||||
}
|
||||
|
||||
let requested_method = request_headers
|
||||
.get("access-control-request-method")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
if !self
|
||||
.allow_methods
|
||||
.iter()
|
||||
.any(|method| method == requested_method)
|
||||
{
|
||||
failures.push("method");
|
||||
}
|
||||
|
||||
let requested_headers = request_headers
|
||||
.get("access-control-request-headers")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
if self.allow_all_headers && requested_headers.is_some() {
|
||||
if let Ok(value) = HeaderValue::from_str(requested_headers.unwrap_or_default()) {
|
||||
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
|
||||
}
|
||||
} else if let Some(requested_headers) = requested_headers {
|
||||
for header in requested_headers.split(',') {
|
||||
if !self
|
||||
.allow_headers_lower
|
||||
.contains(header.trim().to_lowercase().as_str())
|
||||
{
|
||||
failures.push("headers");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (status, body) = if failures.is_empty() {
|
||||
(StatusCode::OK, "OK".to_string())
|
||||
} else {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Disallowed CORS {}", failures.join(", ")),
|
||||
)
|
||||
};
|
||||
|
||||
let mut response = Response::new(axum::body::Body::from(body));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
for (name, value) in headers {
|
||||
if !response.headers().contains_key(&name) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert(headers: &mut Vec<(HeaderName, HeaderValue)>, name: HeaderName, value: HeaderValue) {
|
||||
if let Some(existing) = headers.iter_mut().find(|(existing, _)| *existing == name) {
|
||||
existing.1 = value;
|
||||
} else {
|
||||
headers.push((name, value));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_simple_headers(
|
||||
allow_all_origins: bool,
|
||||
allow_credentials: bool,
|
||||
expose_headers: &[String],
|
||||
) -> Vec<(HeaderName, HeaderValue)> {
|
||||
let mut headers = Vec::new();
|
||||
if allow_all_origins {
|
||||
headers.push((
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_static("*"),
|
||||
));
|
||||
}
|
||||
if allow_credentials {
|
||||
headers.push((
|
||||
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
|
||||
HeaderValue::from_static("true"),
|
||||
));
|
||||
}
|
||||
if !expose_headers.is_empty() {
|
||||
if let Ok(value) = HeaderValue::from_str(&expose_headers.join(", ")) {
|
||||
headers.push((header::ACCESS_CONTROL_EXPOSE_HEADERS, value));
|
||||
}
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn build_preflight_headers(
|
||||
methods: &[String],
|
||||
sorted_allow_headers: &[String],
|
||||
allow_all_headers: bool,
|
||||
allow_credentials: bool,
|
||||
max_age: i64,
|
||||
explicit_allow_origin: bool,
|
||||
) -> Vec<(HeaderName, HeaderValue)> {
|
||||
let mut headers = Vec::new();
|
||||
if explicit_allow_origin {
|
||||
headers.push((header::VARY, HeaderValue::from_static("Origin")));
|
||||
} else {
|
||||
headers.push((
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_static("*"),
|
||||
));
|
||||
}
|
||||
if let Ok(value) = HeaderValue::from_str(&methods.join(", ")) {
|
||||
headers.push((header::ACCESS_CONTROL_ALLOW_METHODS, value));
|
||||
}
|
||||
if let Ok(value) = HeaderValue::from_str(&max_age.to_string()) {
|
||||
headers.push((header::ACCESS_CONTROL_MAX_AGE, value));
|
||||
}
|
||||
if !sorted_allow_headers.is_empty() && !allow_all_headers {
|
||||
if let Ok(value) = HeaderValue::from_str(&sorted_allow_headers.join(", ")) {
|
||||
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
|
||||
}
|
||||
}
|
||||
if allow_credentials {
|
||||
headers.push((
|
||||
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
|
||||
HeaderValue::from_static("true"),
|
||||
));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
/// Merge CORS headers into a response: headers already set by the handler are
|
||||
/// never overwritten; a `Vary` value is appended to an existing header when
|
||||
/// not already present.
|
||||
fn merge_headers(response: &mut Response, headers: Vec<(HeaderName, HeaderValue)>) {
|
||||
for (name, value) in headers {
|
||||
if name == header::VARY {
|
||||
let existing = response
|
||||
.headers()
|
||||
.get(header::VARY)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let present = existing
|
||||
.split(',')
|
||||
.any(|part| part.trim().eq_ignore_ascii_case("origin"));
|
||||
if present {
|
||||
continue;
|
||||
}
|
||||
if existing.is_empty() {
|
||||
response.headers_mut().insert(header::VARY, value);
|
||||
} else {
|
||||
if let Ok(combined) = HeaderValue::from_str(&format!(
|
||||
"{existing}, {}",
|
||||
value.to_str().unwrap_or("Origin")
|
||||
)) {
|
||||
response.headers_mut().insert(header::VARY, combined);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !response.headers().contains_key(&name) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cors_middleware(
|
||||
State(config): State<Arc<CorsConfig>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let origin = request
|
||||
.headers()
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let Some(origin) = origin else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
if request.method() == Method::OPTIONS
|
||||
&& request
|
||||
.headers()
|
||||
.contains_key("access-control-request-method")
|
||||
{
|
||||
return config.preflight_response(request.headers(), &origin);
|
||||
}
|
||||
let headers = config.simple_response_headers(&origin);
|
||||
let mut response = next.run(request).await;
|
||||
merge_headers(&mut response, headers);
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod cors;
|
||||
pub mod logging;
|
||||
pub mod static_files;
|
||||
pub mod telemetry;
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Logging setup for the tavolo application.
|
||||
//!
|
||||
//! The Python server configured logging through `LOGGING_CONFIG` (a
|
||||
//! `logging.config.dictConfig` YAML file). The Rust server deliberately does
|
||||
//! not emulate that schema: logging is configured through `RUST_LOG`
|
||||
//! (tracing-subscriber env filters). `LOGGING_CONFIG` is still accepted so
|
||||
//! existing deployments do not break, but its value is ignored with a
|
||||
//! warning.
|
||||
//!
|
||||
//! When telemetry is enabled the same subscriber also carries the
|
||||
//! OpenTelemetry trace layer and the OTLP log bridge.
|
||||
|
||||
use crate::config::Settings;
|
||||
use crate::telemetry::Telemetry;
|
||||
|
||||
/// Default filter mirrors the Python default (DEBUG for the application) while
|
||||
/// keeping noisy dependencies at INFO+.
|
||||
const DEFAULT_FILTER: &str =
|
||||
"debug,sqlx=warn,redis=warn,hyper=warn,reqwest=warn,tower_http=info,tungstenite=warn";
|
||||
|
||||
pub fn configure_logging(settings: &Settings, telemetry: Option<&Telemetry>) {
|
||||
use tracing_subscriber::layer::{Layer, SubscriberExt};
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
if settings.logging_config.is_some() {
|
||||
eprintln!(
|
||||
"LOGGING_CONFIG is set but the Rust server configures logging via RUST_LOG; \
|
||||
the YAML file is ignored"
|
||||
);
|
||||
}
|
||||
let filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILTER));
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_subscriber::fmt::layer().with_target(true));
|
||||
|
||||
#[cfg(feature = "otel")]
|
||||
if let Some(telemetry) = telemetry {
|
||||
use tracing_subscriber::filter::FilterFn;
|
||||
let telemetry_layer = tracing_opentelemetry::layer().with_tracer(telemetry.tracer());
|
||||
// Drop OpenTelemetry's own records to avoid export loops.
|
||||
let log_bridge = opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(
|
||||
telemetry.logger_provider(),
|
||||
)
|
||||
.with_filter(FilterFn::new(|metadata| {
|
||||
!metadata.target().starts_with("opentelemetry")
|
||||
}));
|
||||
let subscriber = subscriber.with(telemetry_layer).with(log_bridge);
|
||||
let _ = subscriber.try_init();
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "otel"))]
|
||||
let _ = telemetry;
|
||||
|
||||
let _ = subscriber.try_init();
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! The `tavolo` binary: `serve` (default), `migrate` and `backfill-elo`.
|
||||
//!
|
||||
//! `migrate` replaces the Python `aerich upgrade` migration service: it
|
||||
//! applies the embedded sqlx migrations (idempotent against databases already
|
||||
//! at the aerich schema head). `backfill-elo` replaces
|
||||
//! `python -m tavolo.platform.backfill_elo`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use tavolo_app::app;
|
||||
use tavolo_app::config::Settings;
|
||||
use tavolo_app::logging::configure_logging;
|
||||
use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "tavolo",
|
||||
version,
|
||||
about = "Multiplayer card-game platform backend"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Serve the HTTP + WebSocket application (default).
|
||||
Serve,
|
||||
/// Apply the database migrations and exit.
|
||||
Migrate,
|
||||
/// Recompute every Elo rating from the recorded match history.
|
||||
BackfillElo {
|
||||
/// Database URL; defaults to the configured DATABASE_* parts.
|
||||
#[arg(long, env = "DATABASE_URL")]
|
||||
database_url: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
let result = match cli.command.unwrap_or(Command::Serve) {
|
||||
Command::Serve => runtime.block_on(serve()),
|
||||
Command::Migrate => runtime.block_on(migrate()),
|
||||
Command::BackfillElo { database_url } => {
|
||||
runtime.block_on(backfill_elo(database_url.as_deref()))
|
||||
}
|
||||
};
|
||||
if let Err(message) = result {
|
||||
eprintln!("error: {message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve() -> Result<(), String> {
|
||||
use std::sync::Arc;
|
||||
|
||||
use tavolo_platform::stats::{PostgresStatsRepo, SharedStatsRepo};
|
||||
|
||||
let settings = Settings::from_env()?;
|
||||
let registry = app::build_registry(&settings)?;
|
||||
let (session_store, game_store) = app::build_stores(&settings, registry.clone()).await?;
|
||||
let telemetry =
|
||||
tavolo_app::telemetry::init(&settings, session_store.clone(), game_store.clone())?;
|
||||
configure_logging(&settings, telemetry.as_ref());
|
||||
tracing::info!(
|
||||
"registered games: {}",
|
||||
registry
|
||||
.all()
|
||||
.iter()
|
||||
.map(|engine| engine.id())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
if settings.redis_url.is_some() {
|
||||
tracing::info!(
|
||||
"using Redis stores (sessions + live games, game TTL {}s)",
|
||||
settings.game_ttl_seconds
|
||||
);
|
||||
} else {
|
||||
tracing::info!("REDIS_URL unset: using in-memory stores (sessions + live games)");
|
||||
}
|
||||
if telemetry.is_some() {
|
||||
tracing::info!(
|
||||
"OpenTelemetry enabled: service={} endpoint={} (traces, metrics, logs)",
|
||||
settings.otel_service_name,
|
||||
settings
|
||||
.otel_exporter_endpoint
|
||||
.as_deref()
|
||||
.unwrap_or("(OTLP default)"),
|
||||
);
|
||||
}
|
||||
let pool = app::connect_database(&settings).await?;
|
||||
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
|
||||
let platform = app::build_platform(&settings, registry, game_store, stats)?;
|
||||
let router = app::build_router(platform, session_store, &settings);
|
||||
let address: SocketAddr = format!("{}:{}", settings.app_host, settings.app_port)
|
||||
.parse()
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"invalid bind address {}:{}",
|
||||
settings.app_host, settings.app_port
|
||||
)
|
||||
})?;
|
||||
let listener = tokio::net::TcpListener::bind(address)
|
||||
.await
|
||||
.map_err(|err| format!("could not bind {address}: {err}"))?;
|
||||
tracing::info!("listening on http://{address}");
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
.map_err(|err| format!("server error: {err}"))
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("shutting down");
|
||||
}
|
||||
|
||||
fn database_url(override_url: Option<&str>) -> Result<String, String> {
|
||||
match override_url {
|
||||
Some(url) => Ok(url.to_string()),
|
||||
None => Ok(Settings::from_env()?.database_url),
|
||||
}
|
||||
}
|
||||
|
||||
async fn migrate() -> Result<(), String> {
|
||||
let settings = Settings::from_env()?;
|
||||
configure_logging(&settings, None);
|
||||
let url = database_url(None)?;
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&url)
|
||||
.await
|
||||
.map_err(|err| format!("could not connect to the database: {err}"))?;
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.map_err(|err| format!("migration failed: {err}"))?;
|
||||
println!("migrations applied");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backfill_elo(override_url: Option<&str>) -> Result<(), String> {
|
||||
let url = database_url(override_url)?;
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&url)
|
||||
.await
|
||||
.map_err(|err| format!("could not connect to the database: {err}"))?;
|
||||
let repo = PostgresStatsRepo::new(pool);
|
||||
let replayed = repo
|
||||
.backfill_elo()
|
||||
.await
|
||||
.map_err(|err| format!("backfill failed: {err}"))?;
|
||||
println!("Recomputed ratings from {replayed} matches.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! SPA shell hosting and static asset mounts.
|
||||
//!
|
||||
//! Static assets (wasm, js, css, card images) are served by
|
||||
//! [`tower_http::services::ServeDir`] under `/static` (mapped to the frontend
|
||||
//! build root) and `/assets` (mapped to `<root>/assets`), mirroring the
|
||||
//! Granian static mounts of the Python deployment. Every path no API or auth
|
||||
//! route claimed falls back to `index.html`, so client-side routes
|
||||
//! (`/game/<id>`) work on direct loads and refreshes.
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::Request;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use tower::Service;
|
||||
|
||||
/// Fallback service serving the SPA shell, or 404 when the frontend build is
|
||||
/// missing.
|
||||
#[derive(Clone)]
|
||||
pub struct SpaFallback {
|
||||
index: Arc<PathBuf>,
|
||||
}
|
||||
|
||||
impl SpaFallback {
|
||||
pub fn new(static_dir: &str) -> Self {
|
||||
Self {
|
||||
index: Arc::new(Path::new(static_dir).join("index.html")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Service<Request> for SpaFallback {
|
||||
type Response = Response;
|
||||
type Error = Infallible;
|
||||
type Future = std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
|
||||
>;
|
||||
|
||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, _request: Request) -> Self::Future {
|
||||
let index = Arc::clone(&self.index);
|
||||
Box::pin(async move {
|
||||
match tokio::fs::read(index.as_ref()).await {
|
||||
Ok(body) => Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
body,
|
||||
)
|
||||
.into_response()),
|
||||
Err(_) => Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/static` and `/assets` mount directories for a build root.
|
||||
pub fn mount_dirs(static_dir: &str) -> (PathBuf, PathBuf) {
|
||||
let root = PathBuf::from(static_dir);
|
||||
let assets = root.join("assets");
|
||||
(root, assets)
|
||||
}
|
||||
|
||||
/// Build a 404 response body-less response (kept for symmetry with the SPA
|
||||
/// fallback).
|
||||
pub fn not_found() -> Response {
|
||||
Body::empty().into_response()
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
//! OpenTelemetry wiring: OTLP traces, metrics and logs.
|
||||
//!
|
||||
//! Mirrors `tavolo.telemetry` from the Python implementation within what the
|
||||
//! Rust ecosystem offers:
|
||||
//!
|
||||
//! * **traces** — HTTP request spans (tagged with a hashed `session.id` and
|
||||
//! the OIDC `user.id`) and one span per WebSocket connection, exported over
|
||||
//! OTLP/HTTP;
|
||||
//! * **metrics** — `tavolo.active_users` (sessions in the session store),
|
||||
//! `tavolo.ongoing_games` (live matches not finished yet) and Rust process
|
||||
//! metrics (`process.cpu.time`, `process.cpu.utilization`,
|
||||
//! `process.memory.usage`, `process.memory.virtual`) in place of the
|
||||
//! CPython-specific gauges;
|
||||
//! * **logs** — tracing events forwarded to OTLP (OpenTelemetry's own records
|
||||
//! are filtered out upstream).
|
||||
//!
|
||||
//! Database client instrumentation (asyncpg/redis spans in Python) has no
|
||||
//! direct Rust equivalent and is omitted.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Settings;
|
||||
use tavolo_platform::session::SharedSessionStore;
|
||||
use tavolo_platform::store::SharedGameStore;
|
||||
|
||||
#[cfg(feature = "otel")]
|
||||
mod inner {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use opentelemetry_otlp::{
|
||||
LogExporter, MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig,
|
||||
};
|
||||
use opentelemetry_sdk::logs::SdkLoggerProvider;
|
||||
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
|
||||
use opentelemetry_sdk::trace::SdkTracerProvider;
|
||||
use opentelemetry_sdk::Resource;
|
||||
|
||||
use super::{Settings, SharedGameStore, SharedSessionStore};
|
||||
|
||||
pub(super) static HTTP_DURATION: OnceLock<opentelemetry::metrics::Histogram<f64>> =
|
||||
OnceLock::new();
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct ProcessSnapshot {
|
||||
pub cpu_seconds: f64,
|
||||
pub cpu_utilization: f64,
|
||||
pub memory_usage: u64,
|
||||
pub memory_virtual: u64,
|
||||
}
|
||||
|
||||
pub(super) struct OtelInner {
|
||||
pub tracer_provider: SdkTracerProvider,
|
||||
pub meter_provider: SdkMeterProvider,
|
||||
pub logger_provider: SdkLoggerProvider,
|
||||
pub poll_task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for OtelInner {
|
||||
fn drop(&mut self) {
|
||||
self.poll_task.abort();
|
||||
let _ = self.logger_provider.shutdown();
|
||||
let _ = self.meter_provider.shutdown();
|
||||
let _ = self.tracer_provider.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
fn signal_endpoint(base: Option<&str>, signal: &str) -> Option<String> {
|
||||
base.map(|base| format!("{base}/{signal}"))
|
||||
}
|
||||
|
||||
fn parse_headers(raw: Option<&[String]>) -> HashMap<String, String> {
|
||||
raw.unwrap_or(&[])
|
||||
.iter()
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.map(|(key, value)| (key.to_string(), value.trim().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refresh_process(snapshot: &Mutex<ProcessSnapshot>, pid: sysinfo::Pid) {
|
||||
use sysinfo::ProcessesToUpdate;
|
||||
let mut system = sysinfo::System::new();
|
||||
system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
|
||||
if let Some(process) = system.process(pid) {
|
||||
let mut snapshot = snapshot.lock().expect("process snapshot");
|
||||
snapshot.cpu_seconds = process.accumulated_cpu_time() as f64 / 1000.0;
|
||||
snapshot.cpu_utilization = process.cpu_usage() as f64;
|
||||
snapshot.memory_usage = process.memory();
|
||||
snapshot.memory_virtual = process.virtual_memory();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn init(
|
||||
settings: &Settings,
|
||||
session_store: SharedSessionStore,
|
||||
game_store: SharedGameStore,
|
||||
) -> Result<Option<super::Telemetry>, String> {
|
||||
let endpoint = settings.otel_exporter_endpoint.clone();
|
||||
let headers = parse_headers(settings.otel_exporter_headers.as_deref());
|
||||
let resource = Resource::builder()
|
||||
.with_service_name(settings.otel_service_name.clone())
|
||||
.build();
|
||||
|
||||
let span_exporter = SpanExporter::builder()
|
||||
.with_http()
|
||||
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/traces").unwrap_or_default())
|
||||
.with_headers(headers.clone())
|
||||
.build()
|
||||
.map_err(|err| format!("could not build the OTLP span exporter: {err}"))?;
|
||||
let tracer_provider = SdkTracerProvider::builder()
|
||||
.with_batch_exporter(span_exporter)
|
||||
.with_resource(resource.clone())
|
||||
.build();
|
||||
|
||||
let metric_exporter = MetricExporter::builder()
|
||||
.with_http()
|
||||
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/metrics").unwrap_or_default())
|
||||
.with_headers(headers.clone())
|
||||
.build()
|
||||
.map_err(|err| format!("could not build the OTLP metric exporter: {err}"))?;
|
||||
let reader = PeriodicReader::builder(metric_exporter)
|
||||
.with_interval(Duration::from_millis(
|
||||
settings.otel_metric_export_interval_millis.max(1),
|
||||
))
|
||||
.build();
|
||||
let meter_provider = SdkMeterProvider::builder()
|
||||
.with_reader(reader)
|
||||
.with_resource(resource.clone())
|
||||
.build();
|
||||
|
||||
let log_exporter = LogExporter::builder()
|
||||
.with_http()
|
||||
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/logs").unwrap_or_default())
|
||||
.with_headers(headers)
|
||||
.build()
|
||||
.map_err(|err| format!("could not build the OTLP log exporter: {err}"))?;
|
||||
let logger_provider = SdkLoggerProvider::builder()
|
||||
.with_batch_exporter(log_exporter)
|
||||
.with_resource(resource)
|
||||
.build();
|
||||
|
||||
opentelemetry::global::set_meter_provider(meter_provider.clone());
|
||||
let meter = opentelemetry::metrics::MeterProvider::meter(&meter_provider, "tavolo");
|
||||
|
||||
let active_users = Arc::new(AtomicI64::new(0));
|
||||
let ongoing_games = Arc::new(AtomicI64::new(0));
|
||||
let process = Arc::new(Mutex::new(ProcessSnapshot::default()));
|
||||
let pid = sysinfo::get_current_pid()
|
||||
.map_err(|err| format!("could not determine the current process id: {err}"))?;
|
||||
refresh_process(&process, pid);
|
||||
|
||||
let _ = meter
|
||||
.u64_observable_gauge("tavolo.active_users")
|
||||
.with_description("Sessions currently held in the session store.")
|
||||
.with_unit("{user}")
|
||||
.with_callback({
|
||||
let active_users = active_users.clone();
|
||||
move |observer| {
|
||||
observer.observe(active_users.load(Ordering::Relaxed).max(0) as u64, &[])
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let _ = meter
|
||||
.u64_observable_gauge("tavolo.ongoing_games")
|
||||
.with_description("Live games that have not finished yet.")
|
||||
.with_unit("{game}")
|
||||
.with_callback({
|
||||
let ongoing_games = ongoing_games.clone();
|
||||
move |observer| {
|
||||
observer.observe(ongoing_games.load(Ordering::Relaxed).max(0) as u64, &[])
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let _ = meter
|
||||
.f64_observable_gauge("process.cpu.utilization")
|
||||
.with_description("Process CPU utilization since the last poll, in percent.")
|
||||
.with_callback({
|
||||
let process = process.clone();
|
||||
move |observer| {
|
||||
let snapshot = process.lock().expect("process snapshot");
|
||||
observer.observe(snapshot.cpu_utilization / 100.0, &[])
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let _ = meter
|
||||
.u64_observable_gauge("process.memory.usage")
|
||||
.with_description("Process resident memory, in bytes.")
|
||||
.with_unit("By")
|
||||
.with_callback({
|
||||
let process = process.clone();
|
||||
move |observer| {
|
||||
let snapshot = process.lock().expect("process snapshot");
|
||||
observer.observe(snapshot.memory_usage, &[])
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let _ = meter
|
||||
.u64_observable_gauge("process.memory.virtual")
|
||||
.with_description("Process virtual memory, in bytes.")
|
||||
.with_unit("By")
|
||||
.with_callback({
|
||||
let process = process.clone();
|
||||
move |observer| {
|
||||
let snapshot = process.lock().expect("process snapshot");
|
||||
observer.observe(snapshot.memory_virtual, &[])
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let _ = meter
|
||||
.f64_observable_counter("process.cpu.time")
|
||||
.with_description("Process CPU time, in seconds.")
|
||||
.with_unit("s")
|
||||
.with_callback({
|
||||
let process = process.clone();
|
||||
move |observer| {
|
||||
let snapshot = process.lock().expect("process snapshot");
|
||||
observer.observe(snapshot.cpu_seconds, &[]);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
let _ = HTTP_DURATION.set(
|
||||
meter
|
||||
.f64_histogram("http.server.request.duration")
|
||||
.with_description("HTTP server request duration.")
|
||||
.with_unit("s")
|
||||
.build(),
|
||||
);
|
||||
|
||||
let poll_interval = Duration::from_secs(settings.otel_metrics_poll_interval_seconds.max(1));
|
||||
let poll_task = tokio::spawn({
|
||||
let session_store = session_store.clone();
|
||||
let game_store = game_store.clone();
|
||||
let active_users = active_users.clone();
|
||||
let ongoing_games = ongoing_games.clone();
|
||||
let process = process.clone();
|
||||
async move {
|
||||
loop {
|
||||
match session_store.count().await {
|
||||
Ok(count) => active_users.store(count as i64, Ordering::Relaxed),
|
||||
Err(err) => tracing::warn!("active_users refresh failed: {err}"),
|
||||
}
|
||||
match game_store.count_ongoing().await {
|
||||
Ok(count) => ongoing_games.store(count as i64, Ordering::Relaxed),
|
||||
Err(err) => tracing::warn!("ongoing_games refresh failed: {err}"),
|
||||
}
|
||||
let process = process.clone();
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || refresh_process(&process, pid)).await;
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Some(super::Telemetry {
|
||||
inner: OtelInner {
|
||||
tracer_provider,
|
||||
meter_provider,
|
||||
logger_provider,
|
||||
poll_task,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The live telemetry pipeline; dropping it shuts the providers down.
|
||||
pub struct Telemetry {
|
||||
#[cfg(feature = "otel")]
|
||||
inner: inner::OtelInner,
|
||||
}
|
||||
|
||||
#[cfg(feature = "otel")]
|
||||
impl Telemetry {
|
||||
pub fn tracer(&self) -> opentelemetry_sdk::trace::SdkTracer {
|
||||
use opentelemetry::trace::TracerProvider as _;
|
||||
self.inner.tracer_provider.tracer("tavolo")
|
||||
}
|
||||
|
||||
pub fn logger_provider(&self) -> &opentelemetry_sdk::logs::SdkLoggerProvider {
|
||||
&self.inner.logger_provider
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the telemetry pipeline when `OTEL_ENABLED` is set.
|
||||
pub fn init(
|
||||
settings: &Settings,
|
||||
session_store: SharedSessionStore,
|
||||
game_store: SharedGameStore,
|
||||
) -> Result<Option<Telemetry>, String> {
|
||||
if !settings.otel_enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
#[cfg(feature = "otel")]
|
||||
{
|
||||
inner::init(settings, session_store, game_store)
|
||||
}
|
||||
#[cfg(not(feature = "otel"))]
|
||||
{
|
||||
let _ = (session_store, game_store);
|
||||
Err("OTEL_ENABLED is set but the binary was built without the 'otel' feature".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag a span with the hashed session id and the authenticated user.
|
||||
pub fn session_attributes(
|
||||
session: &tavolo_platform::session::SessionData,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
let session_id = session.id().map(hash_session_id);
|
||||
let user = tavolo_platform::auth::session_user(session).map(|user| user.sub);
|
||||
(session_id, user)
|
||||
}
|
||||
|
||||
/// Truncated SHA-256 of the session id (the cookie is a credential, so it is
|
||||
/// never exported verbatim).
|
||||
pub fn hash_session_id(session_id: &str) -> String {
|
||||
tavolo_platform::auth::session_tag(session_id)
|
||||
}
|
||||
|
||||
/// An HTTP span per request, tagged with session/user; excludes the paths in
|
||||
/// `OTEL_EXCLUDED_PATHS`. Also records the HTTP server duration histogram.
|
||||
pub async fn trace_middleware(
|
||||
excluded: Arc<Vec<String>>,
|
||||
request: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use tracing::Instrument;
|
||||
|
||||
let path = request.uri().path().to_string();
|
||||
if excluded.contains(&path) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let method = request.method().clone();
|
||||
let span = tracing::info_span!(
|
||||
"http.server.request",
|
||||
"http.request.method" = %method,
|
||||
"url.path" = %path,
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
"session.id" = tracing::field::Empty,
|
||||
"user.id" = tracing::field::Empty,
|
||||
);
|
||||
let session = request
|
||||
.extensions()
|
||||
.get::<tavolo_platform::session::SessionHandle>()
|
||||
.cloned();
|
||||
async move {
|
||||
let started = std::time::Instant::now();
|
||||
let response = next.run(request).await;
|
||||
let status = response.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
// Recorded after the handler ran so logins/mutations are visible;
|
||||
// the session middleware mints the cookie (and its id on first use)
|
||||
// after this layer returns.
|
||||
if let Some(session) = session {
|
||||
let guard = session.lock().await;
|
||||
let (session_id, user) = session_attributes(&guard);
|
||||
if let Some(session_id) = session_id {
|
||||
tracing::Span::current().record("session.id", session_id);
|
||||
}
|
||||
if let Some(user) = user {
|
||||
tracing::Span::current().record("user.id", user);
|
||||
}
|
||||
}
|
||||
record_http_duration(started.elapsed(), &method, status);
|
||||
response
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
fn record_http_duration(duration: std::time::Duration, method: &axum::http::Method, status: u16) {
|
||||
#[cfg(feature = "otel")]
|
||||
{
|
||||
if let Some(histogram) = inner::HTTP_DURATION.get() {
|
||||
histogram.record(
|
||||
duration.as_secs_f64(),
|
||||
&[
|
||||
opentelemetry::KeyValue::new("http.request.method", method.to_string()),
|
||||
opentelemetry::KeyValue::new("http.response.status_code", status as i64),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "otel"))]
|
||||
{
|
||||
let _ = (duration, method, status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! End-to-end OIDC login flow against a mock provider.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use tavolo_app::app;
|
||||
use tavolo_app::config::Settings;
|
||||
use tavolo_platform::oidc::OidcProviderConfig;
|
||||
|
||||
const RSA_PEM: &str = r#"-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCMKcE4AgsStq5u
|
||||
l3Rtg9p2XNN5l5N5z1orqDJmRHAJvSNqhr2BHxu5Q1m3o8prvE+lHRqe1lgJXKi5
|
||||
8aITv6hDKkS9gmp+tvVQ/VJnnKSbgaSJSMEf5P5GoLR43SrsTEtbxlVO03kwCkiH
|
||||
EoOihK0h4GgyIE/3UWkgsJ8eJKi+czBzBffX8+qfR29vJGEk1mhpHIBnHI+19pLV
|
||||
q0GvMcqKA4hnTZwB43/8EI6lZb59r1ZJLvz87AKd/VnXW4zki16PHntkrrRfRl+U
|
||||
Q5k+ErSYp0SXtyh9mONzxGDQ+ra7gIx8itTnSAbYTwi846UzSTltCCYqTwkvi4nm
|
||||
aCmKn3mdAgMBAAECggEARUGKegC0dPo0JWwLuONKSmriiXWmhq04qlKxGIwfagh9
|
||||
W/EY6Ri0a63GQnGZnvNMzVKcqThTBsk0i7BD/Asa+pkDnzo2IceIXozVL2IcicQc
|
||||
x0LePkK5i69zjTcHMvwdzkqpM0pV2rFyFd/FkEOLgq4t/fquWI9u6RqsfMnzYSqx
|
||||
XxS4JsC6dEgNK69lH2OTWvmpgpujrwWAiJVNlKY0Ehh0R8Wruy7iPjo6NLWH8sQq
|
||||
XlCxIoeWCcPgCbtQRmLgNJPdFB8zSTYGFqGJ5lfYq/7qkcQsAEwIUJBF+EMU+yK5
|
||||
y8jxiGF+8wDg4bYovNxs0uxU/HltHPGDfe1Yn2OyNwKBgQDA7GNsj4hxDME0rXnI
|
||||
8SMnSjOVQRsmNTa6Je3XKXuTxzqXwYBndILmTYOmejYnQJ7oToAn1Jiyk9yo+Mzd
|
||||
2sxKU9iHMVABuLiFmbUm269WB2Zh7fQ7ErhIVGTNsPsHv2iuSuOktGQw6Cd+9pBH
|
||||
0H8HCf9dWOzQ01lqdKw60UfH2wKBgQC5/VnnCtQ4n+aAzSYLYgZf97XTTKN2+gGq
|
||||
z2caPga/3yA+1dPx18JjVqlEuAxazXQPxgAL2ZnGHQfLR+M0lInHhljrALKHTzMl
|
||||
MsmLTq+J8DChlGIPJIKc0Uk4vLSJLMkaHBS9gM9z49zdGx2ltI4gGUJIKk4z5gT0
|
||||
iqRFXM1Z5wKBgAuOna4lMxFCqFhVhBQrjeWOpmRskAnL6klgeE0JaSPDLZlluCF6
|
||||
c1UEk7xnWdMkJeDYwwifzb306aUdsngf4VkaiMAgjiBsRnGKcbWwN2K58sIvFRBs
|
||||
4KlElP1VetRN/SFjzxtQyye64JLUhnQm41Uynh+jchxYbOTZtG+Zlek9AoGADPsP
|
||||
sT5iPWl7jvXCd/zpcP065Xpt1jnwr9k12vEFS5LpDzmhRznFyWUc/zzRL+0y8n7h
|
||||
NfKD9v3Ds35a7yqGyzQnNI+h5iuy6B+5TU26lqf63OwcQ+5KZ2N79sfE2eYUSww2
|
||||
rls1H2OB4LSpxwzo9niM1biOKLUfF2otuOGXO3UCgYBIDAvpabTpw6z4IBQGwHKP
|
||||
WOTFrtI/y0Zqf3iNusqb8RdCIokDHvT+fKdvI7VXzaASWt2GajMHArWB6uu/1Xeh
|
||||
nF9SaK1F+mKyGv9a5m5We9VzRNXmaUaQBRLK3XjBr1Co4/UduYRDdqZo/E/5g8gr
|
||||
BMZbIcEMr49N2ns4yHzJ3w==
|
||||
-----END PRIVATE KEY-----
|
||||
"#;
|
||||
|
||||
const JWK: &str = r#"{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":"jCnBOAILEraubpd0bYPadlzTeZeTec9aK6gyZkRwCb0jaoa9gR8buUNZt6PKa7xPpR0antZYCVyoufGiE7-oQypEvYJqfrb1UP1SZ5ykm4GkiUjBH-T-RqC0eN0q7ExLW8ZVTtN5MApIhxKDooStIeBoMiBP91FpILCfHiSovnMwcwX31_Pqn0dvbyRhJNZoaRyAZxyPtfaS1atBrzHKigOIZ02cAeN__BCOpWW-fa9WSS78_OwCnf1Z11uM5Itejx57ZK60X0ZflEOZPhK0mKdEl7cofZjjc8Rg0Pq2u4CMfIrU50gG2E8IvOOlM0k5bQgmKk8JL4uJ5mgpip95nQ","e":"AQAB"}"#;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct IdpState {
|
||||
issuer: String,
|
||||
nonce: Arc<Mutex<String>>,
|
||||
}
|
||||
|
||||
struct Idp {
|
||||
state: IdpState,
|
||||
addr: SocketAddr,
|
||||
}
|
||||
|
||||
async fn start_idp() -> Idp {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind idp");
|
||||
let addr = listener.local_addr().expect("idp addr");
|
||||
let base = format!("http://{addr}");
|
||||
let state = IdpState {
|
||||
issuer: base.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/.well-known/openid-configuration",
|
||||
get({
|
||||
let base = base.clone();
|
||||
move || async move {
|
||||
Json(json!({
|
||||
"issuer": base,
|
||||
"authorization_endpoint": format!("{base}/authorize"),
|
||||
"token_endpoint": format!("{base}/token"),
|
||||
"userinfo_endpoint": format!("{base}/userinfo"),
|
||||
"jwks_uri": format!("{base}/jwks"),
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/authorize",
|
||||
get(
|
||||
|State(state): State<IdpState>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>| async move {
|
||||
if let Some(nonce) = params.get("nonce") {
|
||||
*state.nonce.lock().await = nonce.clone();
|
||||
}
|
||||
let state_param = params.get("state").cloned().unwrap_or_default();
|
||||
let redirect_uri = params
|
||||
.get("redirect_uri")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let location = format!("{redirect_uri}?code=test-code&state={state_param}");
|
||||
(
|
||||
axum::http::StatusCode::FOUND,
|
||||
[(axum::http::header::LOCATION, location)],
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/token",
|
||||
post(|State(state): State<IdpState>| async move {
|
||||
let nonce = state.nonce.lock().await.clone();
|
||||
let now = Utc::now().timestamp();
|
||||
let claims = json!({
|
||||
"iss": state.issuer,
|
||||
"aud": "tavolo",
|
||||
"sub": "alice",
|
||||
"exp": now + 600,
|
||||
"iat": now,
|
||||
"nonce": nonce,
|
||||
});
|
||||
let token = jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PEM.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
Json(json!({
|
||||
"access_token": "access-token",
|
||||
"token_type": "Bearer",
|
||||
"id_token": token,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/userinfo",
|
||||
get(|| async { Json(json!({"sub": "alice", "preferred_username": "Alice"})) }),
|
||||
)
|
||||
.route(
|
||||
"/jwks",
|
||||
get(|| async { Json(json!({"keys": [serde_json::from_str::<Value>(JWK).unwrap()]})) }),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
Idp { state, addr }
|
||||
}
|
||||
|
||||
fn test_settings(issuer: &str, redirect_uri: &str) -> Settings {
|
||||
Settings {
|
||||
database_url: "postgres://unused".into(),
|
||||
oidc_providers: vec![OidcProviderConfig {
|
||||
provider: "default".into(),
|
||||
issuer: issuer.into(),
|
||||
client_id: "tavolo".into(),
|
||||
client_secret: Some("dev-secret".into()),
|
||||
redirect_uri: redirect_uri.into(),
|
||||
post_login_redirect: "/".into(),
|
||||
post_logout_redirect: "/".into(),
|
||||
}],
|
||||
app_host: "127.0.0.1".into(),
|
||||
app_port: 0,
|
||||
redis_url: None,
|
||||
game_ttl_seconds: 86400,
|
||||
static_dir: "/nonexistent-web-dist".into(),
|
||||
hand_ack_timeout_seconds: 30,
|
||||
turn_timeout_seconds: 30,
|
||||
deadline_heartbeat_ms: 50,
|
||||
logging_config: None,
|
||||
cors_allow_origins: None,
|
||||
cors_allow_origin_regex: None,
|
||||
cors_allow_methods: None,
|
||||
cors_allow_headers: None,
|
||||
cors_allow_credentials: false,
|
||||
cors_expose_headers: None,
|
||||
cors_max_age: 600,
|
||||
otel_enabled: false,
|
||||
otel_service_name: "tavolo".into(),
|
||||
otel_exporter_endpoint: None,
|
||||
otel_exporter_headers: None,
|
||||
otel_excluded_paths: vec!["/api/health".into()],
|
||||
otel_metric_export_interval_millis: 60000,
|
||||
otel_metrics_poll_interval_seconds: 15,
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie_header(response: &axum::response::Response) -> String {
|
||||
let value = response
|
||||
.headers()
|
||||
.get(axum::http::header::SET_COOKIE)
|
||||
.expect("set-cookie")
|
||||
.to_str()
|
||||
.expect("cookie header");
|
||||
value.split(';').next().expect("cookie pair").to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_login_callback_and_logout_flow() {
|
||||
// The mock IdP must know its own issuer before we can start it, so bind
|
||||
// first and rebuild the settings from the recorded address.
|
||||
let idp = start_idp().await;
|
||||
let issuer = format!("http://{}", idp.addr);
|
||||
let callback = "http://localhost:8080/auth/default/callback".to_string();
|
||||
let settings = test_settings(&issuer, &callback);
|
||||
|
||||
let (platform, session_store) = app::in_memory_platform(&settings).expect("platform");
|
||||
let router = app::build_router(platform, session_store.clone(), &settings);
|
||||
|
||||
// 1. Start the login: a 302 to the provider with state/nonce/PKCE and a
|
||||
// fresh session cookie.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/auth/default/login")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 302);
|
||||
let location = response.headers()["location"].to_str().unwrap().to_string();
|
||||
let login_cookie = cookie_header(&response);
|
||||
let url = url::Url::parse(&location).expect("authorization url");
|
||||
let params: std::collections::HashMap<String, String> = url
|
||||
.query_pairs()
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
assert_eq!(params["client_id"], "tavolo");
|
||||
assert_eq!(params["code_challenge_method"], "S256");
|
||||
assert_eq!(params["scope"], "openid email profile");
|
||||
assert_eq!(
|
||||
params["redirect_uri"],
|
||||
"http://localhost:8080/auth/default/callback"
|
||||
);
|
||||
let state = params["state"].clone();
|
||||
// Simulate the browser visiting the provider: the mock signs the nonce
|
||||
// the client generated.
|
||||
*idp.state.nonce.lock().await = params["nonce"].clone();
|
||||
|
||||
// 2. The provider redirects back with the code and state.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri(format!(
|
||||
"/auth/default/callback?code=test-code&state={state}"
|
||||
))
|
||||
.header("cookie", &login_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 302);
|
||||
assert_eq!(response.headers()["location"], "/");
|
||||
let session_cookie = cookie_header(&response);
|
||||
assert_ne!(
|
||||
session_cookie, login_cookie,
|
||||
"session id must rotate on login"
|
||||
);
|
||||
|
||||
// A wrong state is rejected without touching the provider.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/auth/default/callback?code=test-code&state=wrong")
|
||||
.header("cookie", &login_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 400);
|
||||
|
||||
// 3. The session is authenticated and /api/me reflects the userinfo.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/api/me")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let me: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(me, json!({"sub": "alice", "name": "Alice"}));
|
||||
|
||||
// 4. Logout clears the session and sends an expired cookie.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/auth/default/logout")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 302);
|
||||
let clear_cookie = response
|
||||
.headers()
|
||||
.get(axum::http::header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
clear_cookie.contains("Max-Age=0"),
|
||||
"cookie not cleared: {clear_cookie}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Configuration parsing and CORS behavior of the app composition root.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use tavolo_app::app;
|
||||
use tavolo_app::config::Settings;
|
||||
use tavolo_platform::oidc::OidcProviderConfig;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
const ENV_VARS: &[&str] = &[
|
||||
"DATABASE_URL",
|
||||
"DATABASE_ENGINE",
|
||||
"DATABASE_HOST",
|
||||
"DATABASE_PORT",
|
||||
"DATABASE_NAME",
|
||||
"DATABASE_USER",
|
||||
"DATABASE_PASSWORD",
|
||||
"DATABASE_OPTIONS",
|
||||
"APP_HOST",
|
||||
"APP_PORT",
|
||||
"REDIS_URL",
|
||||
"GAME_TTL_SECONDS",
|
||||
"STATIC_DIR",
|
||||
"HAND_ACK_TIMEOUT_SECONDS",
|
||||
"TURN_TIMEOUT_SECONDS",
|
||||
"DEADLINE_HEARTBEAT_MS",
|
||||
"LOGGING_CONFIG",
|
||||
"CORS_ALLOW_ORIGINS",
|
||||
"CORS_ALLOW_ORIGIN_REGEX",
|
||||
"CORS_ALLOW_METHODS",
|
||||
"CORS_ALLOW_HEADERS",
|
||||
"CORS_ALLOW_CREDENTIALS",
|
||||
"CORS_EXPOSE_HEADERS",
|
||||
"CORS_MAX_AGE",
|
||||
"OTEL_ENABLED",
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_EXCLUDED_PATHS",
|
||||
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
|
||||
"OTEL_METRICS_POLL_INTERVAL_SECONDS",
|
||||
"OIDC_ISSUER",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_REDIRECT_URI",
|
||||
"OIDC_POST_LOGIN_REDIRECT",
|
||||
"OIDC_POST_LOGOUT_REDIRECT",
|
||||
];
|
||||
|
||||
fn clear_oidc_env() {
|
||||
for (name, _) in std::env::vars() {
|
||||
if name.starts_with("OIDC_") {
|
||||
std::env::remove_var(&name);
|
||||
}
|
||||
}
|
||||
for name in ENV_VARS {
|
||||
std::env::remove_var(name);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_env<T>(vars: &[(&str, &str)], f: impl FnOnce() -> T) -> T {
|
||||
let _guard = ENV_LOCK.lock().expect("env lock");
|
||||
clear_oidc_env();
|
||||
for (name, value) in vars {
|
||||
std::env::set_var(name, value);
|
||||
}
|
||||
let result = f();
|
||||
clear_oidc_env();
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_python_settings() {
|
||||
let settings = with_env(&[], Settings::from_env).expect("settings");
|
||||
assert_eq!(settings.app_host, "0.0.0.0");
|
||||
assert_eq!(settings.app_port, 8080);
|
||||
assert_eq!(settings.game_ttl_seconds, 86400);
|
||||
assert_eq!(settings.static_dir, "web/dist");
|
||||
assert_eq!(settings.oidc_providers.len(), 1);
|
||||
let provider = &settings.oidc_providers[0];
|
||||
assert_eq!(provider.provider, "default");
|
||||
assert_eq!(provider.issuer, "http://localhost:8180/tavolo");
|
||||
assert_eq!(provider.client_id, "tavolo");
|
||||
assert_eq!(
|
||||
provider.redirect_uri,
|
||||
"http://localhost:8080/auth/default/callback"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.database_url,
|
||||
"postgres://tavolo:password@localhost/tavolo"
|
||||
);
|
||||
assert!(!settings.otel_enabled);
|
||||
assert_eq!(settings.otel_excluded_paths, vec!["/api/health"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_oidc_providers_are_parsed_in_order() {
|
||||
let settings = with_env(
|
||||
&[
|
||||
("OIDC_0_PROVIDER", "google"),
|
||||
("OIDC_0_ISSUER", "https://accounts.google.com"),
|
||||
("OIDC_0_CLIENT_ID", "gid"),
|
||||
("OIDC_0_CLIENT_SECRET", "gsecret"),
|
||||
("OIDC_1_PROVIDER", "github"),
|
||||
("OIDC_1_ISSUER", "https://github.com/login/oauth"),
|
||||
("OIDC_1_CLIENT_ID", "ghid"),
|
||||
],
|
||||
Settings::from_env,
|
||||
)
|
||||
.expect("settings");
|
||||
assert_eq!(settings.oidc_providers.len(), 2);
|
||||
assert_eq!(settings.oidc_providers[0].provider, "google");
|
||||
assert_eq!(
|
||||
settings.oidc_providers[0].redirect_uri,
|
||||
"http://localhost:8080/auth/google/callback"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.oidc_providers[0].client_secret.as_deref(),
|
||||
Some("gsecret")
|
||||
);
|
||||
assert_eq!(settings.oidc_providers[1].provider, "github");
|
||||
assert!(settings.oidc_providers[1].client_secret.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_oidc_configurations_are_rejected() {
|
||||
let error = with_env(
|
||||
&[
|
||||
("OIDC_0_PROVIDER", "a"),
|
||||
("OIDC_0_ISSUER", "https://x"),
|
||||
("OIDC_0_CLIENT_ID", "x"),
|
||||
("OIDC_2_PROVIDER", "b"),
|
||||
("OIDC_2_ISSUER", "https://y"),
|
||||
("OIDC_2_CLIENT_ID", "y"),
|
||||
],
|
||||
Settings::from_env,
|
||||
)
|
||||
.expect_err("missing index must fail");
|
||||
assert!(error.contains("contiguous"), "{error}");
|
||||
|
||||
let error = with_env(&[("OIDC_ISSUER", "https://legacy")], Settings::from_env)
|
||||
.expect_err("legacy variables must fail");
|
||||
assert!(error.contains("indexed per provider"), "{error}");
|
||||
|
||||
let error = with_env(
|
||||
&[
|
||||
("OIDC_0_PROVIDER", "bad name!"),
|
||||
("OIDC_0_ISSUER", "https://x"),
|
||||
("OIDC_0_CLIENT_ID", "x"),
|
||||
],
|
||||
Settings::from_env,
|
||||
)
|
||||
.expect_err("invalid provider names must fail");
|
||||
assert!(error.contains("letters, digits"), "{error}");
|
||||
|
||||
let error = with_env(
|
||||
&[
|
||||
("OIDC_0_PROVIDER", "a"),
|
||||
("OIDC_0_ISSUER", "https://x"),
|
||||
("OIDC_0_CLIENT_ID", "x"),
|
||||
("OIDC_0_WAT", "?"),
|
||||
],
|
||||
Settings::from_env,
|
||||
)
|
||||
.expect_err("unknown variables must fail");
|
||||
assert!(
|
||||
error.contains("unknown OIDC environment variable"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_dsn_quotes_credentials() {
|
||||
let settings = with_env(
|
||||
&[
|
||||
("DATABASE_USER", "ta:v@lo"),
|
||||
("DATABASE_PASSWORD", "p@ss word"),
|
||||
("DATABASE_HOST", "db.example"),
|
||||
("DATABASE_PORT", "5433"),
|
||||
("DATABASE_NAME", "tavolo"),
|
||||
("DATABASE_OPTIONS", "ssl=require"),
|
||||
],
|
||||
Settings::from_env,
|
||||
)
|
||||
.expect("settings");
|
||||
assert_eq!(
|
||||
settings.database_url,
|
||||
"postgres://ta%3Av%40lo:p%40ss%20word@db.example:5433/tavolo?ssl=require"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_is_disabled_without_configuration() {
|
||||
let settings = base_settings();
|
||||
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
|
||||
let router = app::build_router(platform, sessions, &settings);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.header("origin", "https://cards.example")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(response
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_allows_configured_origins_and_regexes() {
|
||||
let mut settings = base_settings();
|
||||
settings.cors_allow_origins = Some(vec!["https://cards.example".into()]);
|
||||
settings.cors_allow_origin_regex = Some(r"https://preview-[a-z0-9-]+\.example\.com".into());
|
||||
settings.cors_allow_methods = Some(vec!["GET".into(), "POST".into()]);
|
||||
settings.cors_max_age = 3600;
|
||||
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
|
||||
let router = app::build_router(platform, sessions, &settings);
|
||||
|
||||
// Allowed origin: echoed. Disallowed origin: no header, request still runs.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.header("origin", "https://cards.example")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.headers()["access-control-allow-origin"],
|
||||
"https://cards.example"
|
||||
);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.header("origin", "https://preview-abc.example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.headers()["access-control-allow-origin"],
|
||||
"https://preview-abc.example.com"
|
||||
);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.header("origin", "https://mallory.example")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(response
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.is_none());
|
||||
|
||||
// Preflight.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri("/api/health")
|
||||
.header("origin", "https://cards.example")
|
||||
.header("access-control-request-method", "POST")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let methods = response.headers()["access-control-allow-methods"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(methods.contains("POST"), "{methods}");
|
||||
assert_eq!(response.headers()["access-control-max-age"], "3600");
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri("/api/health")
|
||||
.header("origin", "https://mallory.example")
|
||||
.header("access-control-request-method", "GET")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_credentials_echo_the_origin_for_wildcards() {
|
||||
let mut settings = base_settings();
|
||||
settings.cors_allow_origins = Some(vec!["*".into()]);
|
||||
settings.cors_allow_credentials = true;
|
||||
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
|
||||
let router = app::build_router(platform, sessions, &settings);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.header("origin", "https://cards.example")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.headers()["access-control-allow-origin"],
|
||||
"https://cards.example"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers()["access-control-allow-credentials"],
|
||||
"true"
|
||||
);
|
||||
}
|
||||
|
||||
fn base_settings() -> Settings {
|
||||
Settings {
|
||||
database_url: "postgres://unused".into(),
|
||||
oidc_providers: vec![OidcProviderConfig {
|
||||
provider: "default".into(),
|
||||
issuer: "http://localhost:8180/tavolo".into(),
|
||||
client_id: "tavolo".into(),
|
||||
client_secret: None,
|
||||
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
|
||||
post_login_redirect: "/".into(),
|
||||
post_logout_redirect: "/".into(),
|
||||
}],
|
||||
app_host: "127.0.0.1".into(),
|
||||
app_port: 0,
|
||||
redis_url: None,
|
||||
game_ttl_seconds: 86400,
|
||||
static_dir: "/nonexistent-web-dist".into(),
|
||||
hand_ack_timeout_seconds: 30,
|
||||
turn_timeout_seconds: 30,
|
||||
deadline_heartbeat_ms: 50,
|
||||
logging_config: None,
|
||||
cors_allow_origins: None,
|
||||
cors_allow_origin_regex: None,
|
||||
cors_allow_methods: None,
|
||||
cors_allow_headers: None,
|
||||
cors_allow_credentials: false,
|
||||
cors_expose_headers: None,
|
||||
cors_max_age: 600,
|
||||
otel_enabled: false,
|
||||
otel_service_name: "tavolo".into(),
|
||||
otel_exporter_endpoint: None,
|
||||
otel_exporter_headers: None,
|
||||
otel_excluded_paths: vec!["/api/health".into()],
|
||||
otel_metric_export_interval_millis: 60000,
|
||||
otel_metrics_poll_interval_seconds: 15,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! End-to-end scopone flow: lobby, four players, live play over the
|
||||
//! WebSocket and the turn-timeout auto-play, plus SPA/static hosting.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Map, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use tavolo_app::app;
|
||||
use tavolo_app::config::Settings;
|
||||
use tavolo_platform::oidc::OidcProviderConfig;
|
||||
use tavolo_platform::session::SharedSessionStore;
|
||||
|
||||
const PLAYERS: [&str; 4] = ["alice", "bob", "carol", "dave"];
|
||||
|
||||
fn settings(static_dir: &str, turn_timeout: i64) -> Settings {
|
||||
Settings {
|
||||
database_url: "postgres://unused".into(),
|
||||
oidc_providers: vec![OidcProviderConfig {
|
||||
provider: "default".into(),
|
||||
issuer: "http://localhost:8180/tavolo".into(),
|
||||
client_id: "tavolo".into(),
|
||||
client_secret: None,
|
||||
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
|
||||
post_login_redirect: "/".into(),
|
||||
post_logout_redirect: "/".into(),
|
||||
}],
|
||||
app_host: "127.0.0.1".into(),
|
||||
app_port: 0,
|
||||
redis_url: None,
|
||||
game_ttl_seconds: 86400,
|
||||
static_dir: static_dir.into(),
|
||||
hand_ack_timeout_seconds: 30,
|
||||
turn_timeout_seconds: turn_timeout,
|
||||
deadline_heartbeat_ms: 50,
|
||||
logging_config: None,
|
||||
cors_allow_origins: None,
|
||||
cors_allow_origin_regex: None,
|
||||
cors_allow_methods: None,
|
||||
cors_allow_headers: None,
|
||||
cors_allow_credentials: false,
|
||||
cors_expose_headers: None,
|
||||
cors_max_age: 600,
|
||||
otel_enabled: false,
|
||||
otel_service_name: "tavolo".into(),
|
||||
otel_exporter_endpoint: None,
|
||||
otel_exporter_headers: None,
|
||||
otel_excluded_paths: vec!["/api/health".into()],
|
||||
otel_metric_export_interval_millis: 60000,
|
||||
otel_metrics_poll_interval_seconds: 15,
|
||||
}
|
||||
}
|
||||
|
||||
async fn login(store: &SharedSessionStore, sub: &str, name: &str) -> String {
|
||||
let id = format!("session-{sub}");
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"oidc:default:user".into(),
|
||||
json!({"sub": sub, "preferred_username": name}),
|
||||
);
|
||||
store.save(&id, &data, Some(3600)).await.expect("session");
|
||||
format!("session_id={id}")
|
||||
}
|
||||
|
||||
async fn body_json(response: axum::response::Response) -> Value {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
serde_json::from_slice(&bytes).expect("json")
|
||||
}
|
||||
|
||||
fn post(uri: &str, cookie: &str, body: &Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(body).unwrap()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
|
||||
let mut builder = Request::builder().uri(uri);
|
||||
if let Some(cookie) = cookie {
|
||||
builder = builder.header("cookie", cookie);
|
||||
}
|
||||
builder.body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
async fn start_server(router: axum::Router) -> SocketAddr {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn ws_request(addr: SocketAddr, game_id: &str, cookie: &str) -> Request<()> {
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
let mut request = format!("ws://{addr}/ws/games/{game_id}")
|
||||
.into_client_request()
|
||||
.expect("request");
|
||||
request.headers_mut().insert(
|
||||
"cookie",
|
||||
axum::http::HeaderValue::from_str(cookie).expect("cookie"),
|
||||
);
|
||||
request
|
||||
}
|
||||
|
||||
async fn read_json(
|
||||
socket: &mut tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
) -> Value {
|
||||
let message = socket.next().await.expect("message").expect("ok");
|
||||
match message {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
serde_json::from_str(&text).expect("json")
|
||||
}
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scopone_lobby_play_and_turn_timeout() {
|
||||
let temp = std::env::temp_dir().join(format!("tavolo-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
|
||||
let test_settings = settings(temp.to_str().unwrap(), 1);
|
||||
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
|
||||
let router = app::build_router(platform, sessions.clone(), &test_settings);
|
||||
|
||||
// Create the game as alice and join with the other three players.
|
||||
let alice = login(&sessions, "alice", "Alice").await;
|
||||
let created = router
|
||||
.clone()
|
||||
.oneshot(post(
|
||||
"/api/games",
|
||||
&alice,
|
||||
&json!({"options": {"target_score": 11}}),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let lobby = body_json(created).await;
|
||||
let game_id = lobby["id"].as_str().unwrap().to_string();
|
||||
let code = lobby["join_code"].as_str().unwrap().to_string();
|
||||
assert_eq!(lobby["seats_open"], 3);
|
||||
assert_eq!(lobby["players"][0]["team"], "A");
|
||||
|
||||
for (index, player) in PLAYERS.iter().enumerate().skip(1) {
|
||||
let cookie = login(&sessions, player, &player.to_uppercase()).await;
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(post("/api/games/join", &cookie, &json!({"code": code})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "player {player}");
|
||||
let body = body_json(response).await;
|
||||
assert_eq!(body["players"].as_array().unwrap().len(), index + 1);
|
||||
if index == 3 {
|
||||
assert_eq!(body["phase"], "playing");
|
||||
assert_eq!(body["hand_number"], 1);
|
||||
assert_eq!(body["dealer"], 0);
|
||||
assert_eq!(body["turn"], 1);
|
||||
assert_eq!(body["players"][index]["hand"].as_array().unwrap().len(), 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Bob is on turn (seat 1): his hand is visible to him and hidden from
|
||||
// alice's personalized view.
|
||||
let bob = login(&sessions, "bob", "Bob").await;
|
||||
let bob_view = body_json(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/games/{game_id}"), Some(&bob)))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(bob_view["turn"], 1);
|
||||
assert_eq!(bob_view["your_turn"], true);
|
||||
assert_eq!(bob_view["players"][1]["hand"].as_array().unwrap().len(), 10);
|
||||
assert!(bob_view["players"][0].get("hand").is_none());
|
||||
assert!(bob_view["legal_moves"].is_object());
|
||||
|
||||
let alice_view = body_json(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
alice_view["players"][0]["hand"].as_array().unwrap().len(),
|
||||
10
|
||||
);
|
||||
assert!(alice_view["players"][1].get("hand").is_none());
|
||||
|
||||
let addr = start_server(router.clone()).await;
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(ws_request(addr, &game_id, &bob))
|
||||
.await
|
||||
.expect("bob connects");
|
||||
let state = read_json(&mut ws).await;
|
||||
assert_eq!(state["type"], "state");
|
||||
// The table starts empty and the turn belongs to bob (seat 1).
|
||||
assert_eq!(state["game"]["turn"], 1);
|
||||
assert_eq!(state["game"]["table"].as_array().unwrap().len(), 0);
|
||||
// Bob is on turn on the websocket view.
|
||||
assert_eq!(state["game"]["your_turn"], true);
|
||||
let hand = state["game"]["players"][1]["hand"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let card = hand[0].as_str().unwrap().to_string();
|
||||
let legal = state["game"]["legal_moves"]
|
||||
.as_object()
|
||||
.and_then(|moves| moves.get(&card))
|
||||
.cloned();
|
||||
let mut message = json!({"action": "play", "card": card});
|
||||
if let Some(legal) = legal {
|
||||
if let Some(option) = legal.as_array().and_then(|options| options.first()) {
|
||||
message["capture"] = option.clone();
|
||||
}
|
||||
}
|
||||
ws.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||
message.to_string(),
|
||||
))
|
||||
.await
|
||||
.expect("send play");
|
||||
let update = read_json(&mut ws).await;
|
||||
assert_eq!(update["type"], "state");
|
||||
assert_eq!(update["game"]["turn"], 2);
|
||||
assert_eq!(update["game"]["last_move"]["seat"], 1);
|
||||
assert_eq!(update["game"]["last_move"]["card"], card);
|
||||
|
||||
// Carol (seat 2) is on turn now with a 1s timeout: the server auto-plays.
|
||||
let before = update["game"]["players"][2]["cards_left"].as_u64().unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1800)).await;
|
||||
let after = body_json(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(after["turn"], 3);
|
||||
assert_eq!(
|
||||
after["players"][2]["cards_left"].as_u64().unwrap(),
|
||||
before - 1
|
||||
);
|
||||
assert_eq!(after["last_move"]["seat"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spa_shell_and_static_mounts() {
|
||||
let temp = std::env::temp_dir().join(format!("tavolo-static-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
|
||||
std::fs::write(temp.join("index.html"), "<html>app shell</html>").expect("index");
|
||||
std::fs::write(temp.join("assets/card.png"), b"png-bytes").expect("asset");
|
||||
let test_settings = settings(temp.to_str().unwrap(), 30);
|
||||
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
|
||||
let router = app::build_router(platform, sessions, &test_settings);
|
||||
|
||||
let response = router.clone().oneshot(get("/", None)).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/game/abc-123", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "SPA fallback");
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/static/index.html", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/assets/card.png", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&bytes[..], b"png-bytes");
|
||||
|
||||
// Unknown API paths also fall back to the SPA shell, like kaya's glob;
|
||||
// an unknown OIDC provider path (no route registered) does too.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/api/does-not-exist", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/auth/nope/login", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
std::fs::remove_dir_all(temp).ok();
|
||||
}
|
||||
Reference in new issue
Block a user