Rewrite the backend in Rust with Axum
CI / Build and push docker image (push) Successful in 4m20s

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:
2026-10-02 13:54:55 +00:00
parent adc3d340b1
commit 7b3b38ccbc
136 changed files with 16821 additions and 11777 deletions
@@ -0,0 +1,531 @@
//! Server-side HTTP sessions.
//!
//! Mirrors kaya's `SessionMixin` / `SessionStore`:
//!
//! * [`SessionData`] is a dict-like container with modification tracking
//! (`modified` / `regenerate` / `invalidated` flags) used by the
//! middleware to decide whether to persist, rotate or delete it;
//! * [`SessionStore`] is the pluggable backend: Redis in production,
//! in-memory for tests and ephemeral single-process dev;
//! * [`session_middleware`] loads the session from the `session_id` cookie,
//! exposes it as a [`SessionHandle`] request extension, and persists it
//! (refreshing the cookie) after the handler ran.
//!
//! Session data is serialized as JSON (the Python server used pickle; old
//! sessions are deliberately not readable, users log in again).
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::Response;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::RngCore;
use serde_json::{Map, Value};
use tokio::sync::{Mutex, MutexGuard};
use crate::errors::{Error, Result};
/// The session cookie name (kaya's default).
pub const COOKIE_NAME: &str = "session_id";
/// The session idle timeout (kaya's default: 14 days).
pub const MAX_AGE_SECONDS: i64 = 14 * 24 * 60 * 60;
/// Key prefix of the Redis session store. The prefix differs from kaya's
/// (`kaya:session:`) because the payload format changed from pickle to JSON.
pub const REDIS_KEY_PREFIX: &str = "tavolo:session:";
/// Opaque session identifier with 256 bits of entropy, matching Python's
/// `secrets.token_urlsafe(32)` (43 URL-safe base64 characters, no padding).
pub fn new_session_id() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Dict-like session container with modification tracking.
#[derive(Debug, Clone, Default)]
pub struct SessionData {
id: Option<String>,
old_id: Option<String>,
data: Map<String, Value>,
modified: bool,
regenerate: bool,
invalidated: bool,
}
impl SessionData {
pub fn new() -> Self {
Self::default()
}
pub fn with_id(id: impl Into<String>, data: Map<String, Value>) -> Self {
Self {
id: Some(id.into()),
old_id: None,
data,
modified: false,
regenerate: false,
invalidated: false,
}
}
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn is_modified(&self) -> bool {
self.modified
}
pub fn is_regenerate(&self) -> bool {
self.regenerate
}
pub fn is_invalidated(&self) -> bool {
self.invalidated
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.data.get(key)
}
pub fn insert(&mut self, key: impl Into<String>, value: Value) {
self.data.insert(key.into(), value);
self.modified = true;
}
pub fn remove(&mut self, key: &str) -> Option<Value> {
let removed = self.data.remove(key);
if removed.is_some() {
self.modified = true;
}
removed
}
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.data.keys()
}
pub fn data(&self) -> &Map<String, Value> {
&self.data
}
pub fn mark_modified(&mut self) {
self.modified = true;
}
/// Mark the session for ID rotation (session fixation defense); the
/// middleware mints a new id and deletes the old store entry.
pub fn regenerate_id(&mut self) {
self.old_id = self.id.take();
self.regenerate = true;
self.modified = true;
}
/// Mark the session for deletion and send an expired cookie.
pub fn invalidate(&mut self) {
self.invalidated = true;
self.modified = true;
self.data.clear();
}
}
/// Shared, mutable session attached to a request.
#[derive(Clone)]
pub struct SessionHandle(pub Arc<Mutex<SessionData>>);
impl SessionHandle {
pub fn new(session: SessionData) -> Self {
Self(Arc::new(Mutex::new(session)))
}
pub async fn lock(&self) -> MutexGuard<'_, SessionData> {
self.0.lock().await
}
}
/// Pluggable backend for session persistence.
#[async_trait]
pub trait SessionStore: Send + Sync {
/// Load an existing session, or `None` if unknown or expired. When
/// `max_age` is given the store slides the server-side expiry.
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>>;
/// Persist the session data under `session_id`.
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()>;
/// Remove the session from the store.
async fn delete(&self, session_id: &str) -> Result<()>;
/// Number of live sessions (for the `tavolo.active_users` gauge).
async fn count(&self) -> Result<usize>;
}
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
struct MemorySession {
data: Map<String, Value>,
expires: Option<Instant>,
}
/// Process-local session store used by tests and when Redis is not configured.
#[derive(Default)]
pub struct InMemorySessionStore {
sessions: Mutex<HashMap<String, MemorySession>>,
}
impl InMemorySessionStore {
pub fn new() -> Self {
Self::default()
}
fn clock(&self) -> Instant {
Instant::now()
}
}
#[async_trait]
impl SessionStore for InMemorySessionStore {
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
let mut sessions = self.sessions.lock().await;
let Some(entry) = sessions.get_mut(session_id) else {
return Ok(None);
};
let now = self.clock();
if let Some(expires) = entry.expires {
if now > expires {
sessions.remove(session_id);
return Ok(None);
}
}
if max_age.is_some() {
entry.expires = Some(now + Duration::from_secs(max_age.unwrap_or(0) as u64));
}
Ok(Some(SessionData::with_id(
session_id.to_string(),
entry.data.clone(),
)))
}
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()> {
let mut sessions = self.sessions.lock().await;
sessions.insert(
session_id.to_string(),
MemorySession {
data: data.clone(),
expires: max_age.map(|age| self.clock() + Duration::from_secs(age as u64)),
},
);
Ok(())
}
async fn delete(&self, session_id: &str) -> Result<()> {
self.sessions.lock().await.remove(session_id);
Ok(())
}
async fn count(&self) -> Result<usize> {
let sessions = self.sessions.lock().await;
let now = self.clock();
Ok(sessions
.values()
.filter(|entry| entry.expires.is_none_or(|expires| expires > now))
.count())
}
}
// ---------------------------------------------------------------------------
// Redis store
// ---------------------------------------------------------------------------
/// Redis-backed session store: one JSON value per session under
/// `tavolo:session:<id>`, expiry delegated to the key TTL.
pub struct RedisSessionStore {
client: redis::Client,
manager: redis::aio::ConnectionManager,
prefix: String,
}
impl RedisSessionStore {
pub async fn new(url: &str) -> Result<Self> {
let client = redis::Client::open(url)?;
let manager = redis::aio::ConnectionManager::new(client.clone()).await?;
Ok(Self {
client,
manager,
prefix: REDIS_KEY_PREFIX.to_string(),
})
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
pub fn client(&self) -> redis::Client {
self.client.clone()
}
fn key(&self, session_id: &str) -> String {
format!("{}{}", self.prefix, session_id)
}
}
#[async_trait]
impl SessionStore for RedisSessionStore {
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
let mut conn = self.manager.clone();
let key = self.key(session_id);
let payload: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await?;
let Some(payload) = payload else {
return Ok(None);
};
if let Some(max_age) = max_age {
redis::cmd("EXPIRE")
.arg(&key)
.arg(max_age)
.query_async::<i64>(&mut conn)
.await?;
}
let data: Map<String, Value> = serde_json::from_str(&payload)?;
Ok(Some(SessionData::with_id(session_id.to_string(), data)))
}
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()> {
let mut conn = self.manager.clone();
let payload = serde_json::to_string(data)?;
let mut cmd = redis::cmd("SET");
cmd.arg(self.key(session_id)).arg(payload);
if let Some(max_age) = max_age {
cmd.arg("EX").arg(max_age);
}
cmd.query_async::<()>(&mut conn).await?;
Ok(())
}
async fn delete(&self, session_id: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("DEL")
.arg(self.key(session_id))
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn count(&self) -> Result<usize> {
let mut conn = self.manager.clone();
let mut cursor: u64 = 0;
let mut total = 0usize;
loop {
let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(format!("{}*", self.prefix))
.arg("COUNT")
.arg(500)
.query_async(&mut conn)
.await?;
total += keys.len();
cursor = next;
if cursor == 0 {
break;
}
}
Ok(total)
}
}
/// Convenience alias used by the application.
pub type SharedSessionStore = Arc<dyn SessionStore>;
// ---------------------------------------------------------------------------
// Cookie helpers
// ---------------------------------------------------------------------------
/// Return the value of `name` from a `Cookie` header, if present.
pub fn parse_cookie_value(header_value: &str, name: &str) -> Option<String> {
for part in header_value.split(';') {
let part = part.trim();
if let Some((key, value)) = part.split_once('=') {
if key.trim() == name {
return Some(value.trim().trim_matches('"').to_string());
}
}
}
None
}
/// Build a `Set-Cookie` value the way Python's `SimpleCookie` renders one,
/// i.e. sorted attributes: `HttpOnly; Max-Age=n; Path=/; SameSite=Lax`.
pub fn format_set_cookie(
name: &str,
value: &str,
path: &str,
max_age: Option<i64>,
httponly: bool,
secure: bool,
samesite: Option<&str>,
) -> String {
let mut out = format!("{name}={value}");
if httponly {
out.push_str("; HttpOnly");
}
if let Some(max_age) = max_age {
out.push_str(&format!("; Max-Age={max_age}"));
}
out.push_str(&format!("; Path={path}"));
if let Some(samesite) = samesite {
out.push_str(&format!("; SameSite={samesite}"));
}
if secure {
out.push_str("; Secure");
}
out
}
fn extract_session_id(headers: &axum::http::HeaderMap) -> Option<String> {
let mut found = None;
for value in headers.get_all(axum::http::header::COOKIE) {
if let Ok(value) = value.to_str() {
if let Some(id) = parse_cookie_value(value, COOKIE_NAME) {
found = Some(id);
break;
}
}
}
found
}
/// Load the session, expose it as a [`SessionHandle`] extension and persist
/// it (refreshing the cookie) after the handler ran.
///
/// Mirrors kaya's `SessionMixin`: an existing session is always re-saved and
/// its cookie re-sent, so the client and server expiry windows slide on
/// every request.
pub async fn session_middleware(
State(store): State<SharedSessionStore>,
mut request: Request,
next: Next,
) -> Response {
// Static assets never reach the application in the Python deployment
// (Granian serves them): skip the session round-trip for them.
let path = request.uri().path();
if path.starts_with("/static") || path.starts_with("/assets") {
return next.run(request).await;
}
let session_id = extract_session_id(request.headers());
let session = match session_id.as_deref() {
Some(id) => match store.load(id, Some(MAX_AGE_SECONDS)).await {
Ok(Some(session)) => session,
Ok(None) => SessionData::new(),
Err(err) => {
tracing::error!("session load failed: {err}");
SessionData::new()
}
},
None => SessionData::new(),
};
let handle = SessionHandle::new(session);
request.extensions_mut().insert(handle.clone());
let mut response = next.run(request).await;
let mut session = handle.lock().await;
let mut cookie: Option<String> = None;
if session.invalidated {
let id = session.old_id.clone().or_else(|| session.id.clone());
if let Some(id) = &id {
if let Err(err) = store.delete(id).await {
tracing::error!("session delete failed: {err}");
}
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
id.as_deref().unwrap_or(""),
"/",
Some(0),
true,
false,
Some("Lax"),
));
} else if session.regenerate {
if let Some(old_id) = session.old_id.clone() {
if let Err(err) = store.delete(&old_id).await {
tracing::error!("session delete failed: {err}");
}
session.old_id = None;
}
if session.id.is_none() {
session.id = Some(new_session_id());
}
session.regenerate = false;
if let Some(id) = session.id.clone() {
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
tracing::error!("session save failed: {err}");
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
&id,
"/",
Some(MAX_AGE_SECONDS),
true,
false,
Some("Lax"),
));
}
} else {
if session.id.is_none() && session.is_modified() {
session.id = Some(new_session_id());
}
if let Some(id) = session.id.clone() {
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
tracing::error!("session save failed: {err}");
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
&id,
"/",
Some(MAX_AGE_SECONDS),
true,
false,
Some("Lax"),
));
}
}
drop(session);
if let Some(cookie) = cookie {
if let Ok(value) = axum::http::HeaderValue::from_str(&cookie) {
response
.headers_mut()
.append(axum::http::header::SET_COOKIE, value);
}
}
response
}
/// Errors from the session layer surfaced as platform errors.
pub fn session_error(message: impl Into<String>) -> Error {
Error::Internal(message.into())
}